Post

Private Pi-hole DNS over Headscale with DNSCrypt and Authentik

How to run Pi-hole privately behind Headscale/Tailscale, use DNSCrypt as the upstream resolver, and protect the Pi-hole web dashboard with Authentik and Traefik.

Private Pi-hole DNS over Headscale with DNSCrypt and Authentik

This guide documents a private DNS filtering setup for a homelab where phones and laptops connected to Headscale use Pi-hole as their DNS resolver.

All domains and IP addresses in this public guide are examples. Replace them with your own private values in your real configuration, but do not publish real public IPs, phone tailnet IPs, or personal device addresses in documentation.

The important part is that Pi-hole DNS is not exposed as an open public resolver. Instead, the Server itself joins the Headscale network and receives a private tailnet IP. Headscale then advertises that private IP as the DNS server for connected devices.

In this setup:

  • Headscale controls the private Tailscale-compatible network.
  • The Server joins that network as a normal node named server-pihole.
  • Pi-hole listens on the Server’s private tailnet IP.
  • Headscale clients receive Pi-hole as their DNS server.
  • dnscrypt-proxy is Pi-hole’s upstream resolver.
  • Authentik protects the Pi-hole web dashboard.
  • Traefik publishes the dashboard at https://pihole.example.com.

The result is simple from the phone’s point of view:

1
2
3
4
5
6
7
8
9
Phone connects to Headscale
  ↓
Phone receives DNS server 100.64.10.10
  ↓
Phone asks Pi-hole for DNS
  ↓
Pi-hole blocks unwanted domains
  ↓
Allowed queries go through dnscrypt-proxy

Final architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
                         Public HTTPS access

Browser
  ↓
https://pihole.example.com
  ↓
Traefik
  ↓
Authentik forward auth
  ↓
Pi-hole Web UI


                         Private DNS path

Phone / laptop
  ↓
Headscale / Tailscale tunnel
  ↓
100.64.10.10:53
  ↓
Pi-hole
  ↓
dnscrypt-proxy
  ↓
Cloudflare DoH

The web dashboard and the DNS resolver are related, but they are not the same path.

  • The web dashboard goes through HTTPS, Traefik, and Authentik.
  • The DNS resolver goes through Headscale/Tailscale to Pi-hole on port 53.

Authentik protects the admin interface. Pi-hole still does the DNS blocking.

Real service layout

On the Server, the relevant folders are:

1
2
3
/home/ubuntu/headscale/
/home/ubuntu/pihole/
/home/ubuntu/authentik/

Important files:

1
2
3
4
5
6
/home/ubuntu/headscale/config/config.yaml
/home/ubuntu/headscale/docker-compose.yml
/home/ubuntu/pihole/.env
/home/ubuntu/pihole/docker-compose.yml
/home/ubuntu/authentik/docker-compose.yml
/etc/systemd/resolved.conf

The active public services are:

1
2
3
4
https://headscale.example.com
https://headscale-ui.example.com
https://pihole.example.com
https://auth.example.com

Why the Server must join Headscale

This is the key idea.

Pi-hole runs on the Server. The phone is somewhere else: mobile network, Wi-Fi, or another network entirely. If the phone is connected to Headscale, it can reach other Headscale nodes using private 100.x.x.x addresses.

Before the Server joined Headscale, there were only bad options.

Bad option: advertise the public IP as DNS

For example:

1
2
3
nameservers:
  global:
    - 203.0.113.10

This tells phones to use the server’s public IP for DNS.

That only works if Pi-hole listens publicly on port 53:

1
0.0.0.0:53

But exposing DNS publicly is dangerous. It can turn the server into an open DNS resolver. Open resolvers are abused for DNS amplification attacks, scanning, and unwanted public traffic.

Even with firewall rules, it is easy to make a mistake.

Better option: use a private tailnet IP

The cleaner design is:

1
2
3
4
5
6
7
Server joins Headscale
  ↓
Server receives 100.64.10.10
  ↓
Pi-hole listens on 100.64.10.10:53
  ↓
Headscale advertises 100.64.10.10 as DNS

Now only devices connected to Headscale can use Pi-hole.

This is why the Server was joined to Headscale as:

1
server-pihole

It received this private Headscale/Tailscale IP:

1
100.64.10.10

That IP became the DNS server for the tailnet.

Headscale DNS configuration

The Headscale DNS config lives here:

1
/home/ubuntu/headscale/config/config.yaml

Relevant section:

1
2
3
4
5
6
7
8
9
10
11
dns:
  magic_dns: true
  base_domain: tail.example.com
  override_local_dns: true
  nameservers:
    global:
      # Only DNS advertised to Headscale/Tailscale clients: Pi-hole
      - 100.64.10.10
    split: {}
  search_domains: []
  extra_records: []

Meaning:

  • magic_dns: true enables Headscale/Tailscale DNS features.
  • base_domain: tail.example.com is the internal tailnet DNS suffix.
  • override_local_dns: true tells clients to use Headscale-provided DNS instead of their local network DNS.
  • 100.64.10.10 is the Server’s private tailnet IP.

After changing this file, restart Headscale:

1
2
cd /home/ubuntu/headscale
docker compose restart headscale

Then reconnect the phone’s Tailscale/Headscale VPN so it receives the new DNS settings.

Pi-hole binding

Pi-hole is configured in:

1
/home/ubuntu/pihole/docker-compose.yml

Pi-hole DNS listens on two addresses:

1
2
3
4
5
ports:
  - "127.0.0.1:53:53/tcp"
  - "127.0.0.1:53:53/udp"
  - "100.64.10.10:53:53/tcp"
  - "100.64.10.10:53:53/udp"

Why both?

1
127.0.0.1:53

is useful for local tests from the Server.

1
100.64.10.10:53

is the private DNS endpoint used by Headscale clients.

Pi-hole is not listening on public 0.0.0.0:53, which avoids exposing an open resolver to the internet.

Allowing tailnet DNS clients

Pi-hole v6 may ignore DNS queries from networks it does not consider local. When testing through the tailnet IP, Pi-hole logged:

1
dnsmasq: ignoring query from non-local network 100.64.10.20

The fix is to tell Pi-hole to listen for all allowed interfaces/networks:

1
FTLCONF_dns_listeningMode: "all"

In the Pi-hole service environment:

1
2
environment:
  FTLCONF_dns_listeningMode: "all" # Allow DNS from Headscale/Tailscale clients

This does not mean DNS is exposed publicly, because Docker still only publishes DNS on:

1
2
127.0.0.1
100.64.10.10

The bind address remains the real security boundary.

Pi-hole upstream DNS through dnscrypt-proxy

Pi-hole is the filter. dnscrypt-proxy is the encrypted upstream resolver.

In /home/ubuntu/pihole/docker-compose.yml, Pi-hole uses:

1
FTLCONF_dns_upstreams: "dnscrypt-proxy#53"

That means:

1
2
3
4
5
Pi-hole
  ↓
dnscrypt-proxy container on Docker network
  ↓
Cloudflare DoH

The dnscrypt-proxy service is configured like this:

1
2
3
4
5
6
7
8
9
10
dnscrypt-proxy:
  container_name: dnscrypt-proxy
  image: klutchell/dnscrypt-proxy:latest
  environment:
    - TZ=Europe/London
    - DNSCRYPT_PROXY_LISTEN_ADDRESSES=[0.0.0.0]:53
    - DNSCRYPT_PROXY_RESOLVER_NAME=cloudflare
    - DNSCRYPT_PROXY_RESOLVER_ADDRESS=1.1.1.1:53
  networks:
    - proxy

Inside Docker, Pi-hole can resolve the service name:

1
dnscrypt-proxy

So Pi-hole does not need to know the container IP.

To verify the upstream setting:

1
docker exec pihole pihole-FTL --config dns.upstreams

Expected result:

1
[ dnscrypt-proxy#53 ]

To verify dnscrypt-proxy itself:

1
docker logs dnscrypt-proxy --tail=100

Expected useful line:

1
[cloudflare] OK (DoH)

systemd-resolved and port 53

On Ubuntu, systemd-resolved often binds local DNS stub ports:

1
2
127.0.0.53:53
127.0.0.54:53

That can conflict with Pi-hole if Pi-hole tries to bind all interfaces.

The resolved config file is:

1
/etc/systemd/resolved.conf

The relevant setting is:

1
DNSStubListener=no

Then restart it:

1
sudo systemctl restart systemd-resolved

In this setup, Pi-hole owns:

1
2
127.0.0.1:53
100.64.10.10:53

and systemd-resolved no longer conflicts with those addresses.

Blocking Netflix with wildcard DNS

A wildcard Pi-hole block for Netflix looks like:

1
(\.|^)netflix\.com$

When Pi-hole receives a matching query, it returns:

1
0.0.0.0

Test from the Server:

1
dig @100.64.10.10 netflix.com +short

Expected result:

1
0.0.0.0

If this works on the Server but the phone still opens Netflix, usually one of these is happening:

  1. The phone has not reconnected to Headscale after the DNS change.
  2. The phone cached DNS before the block was added.
  3. The Netflix app cached resolved IPs.
  4. Android/iOS private DNS or browser DoH is bypassing system DNS.
  5. The app is using hardcoded endpoints that need additional domains blocked.

First fix to try:

1
Toggle Tailscale/Headscale VPN off and back on.

Then force close the Netflix app and reopen it.

Authentik protection for the Pi-hole web UI

The Pi-hole web dashboard is published at:

1
https://pihole.example.com/admin/

Traefik routes the HTTPS request to Pi-hole, but the secure router uses Authentik middleware:

1
- "traefik.http.routers.pihole-secure.middlewares=authentik@docker"

The relevant labels in /home/ubuntu/pihole/docker-compose.yml are:

1
2
3
4
5
6
7
8
9
10
11
12
13
labels:
  - "traefik.enable=true"
  - "traefik.http.routers.pihole.entrypoints=http"
  - "traefik.http.routers.pihole.rule=Host(`pihole.example.com`)"
  - "traefik.http.middlewares.pihole-https-redirect.redirectscheme.scheme=https"
  - "traefik.http.routers.pihole.middlewares=pihole-https-redirect"
  - "traefik.http.routers.pihole-secure.entrypoints=https"
  - "traefik.http.routers.pihole-secure.rule=Host(`pihole.example.com`)"
  - "traefik.http.routers.pihole-secure.middlewares=authentik@docker"
  - "traefik.http.routers.pihole-secure.tls=true"
  - "traefik.http.routers.pihole-secure.service=pihole"
  - "traefik.http.services.pihole.loadbalancer.server.port=80"
  - "traefik.docker.network=proxy"

Authentik also needs an outpost callback route for the same host. In /home/ubuntu/authentik/docker-compose.yml, the embedded Authentik outpost has a Pi-hole route:

1
2
3
4
5
6
- "traefik.http.routers.authentik-pihole-outpost.entrypoints=https"
- "traefik.http.routers.authentik-pihole-outpost.rule=Host(`pihole.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.authentik-pihole-outpost.priority=100"
- "traefik.http.routers.authentik-pihole-outpost.tls=true"
- "traefik.http.routers.authentik-pihole-outpost.tls.certresolver=cloudflare"
- "traefik.http.routers.authentik-pihole-outpost.service=authentik"

In Authentik itself, there is a proxy application/provider:

1
Pi-hole Web UI

External host:

1
https://pihole.example.com

Mode:

1
forward_single

Attached outpost:

1
authentik Embedded Outpost

Authentik protects the web UI, not DNS

This is important.

Authentik protects:

1
https://pihole.example.com/admin/

It does not protect:

1
100.64.10.10:53

DNS clients cannot log in through Authentik. They just send DNS packets. That is why the DNS side is protected by using a private Headscale IP instead of public DNS exposure.

So there are two separate security layers:

1
2
3
4
5
Web UI security:
Traefik → Authentik → Pi-hole dashboard

DNS security:
Headscale private network → Pi-hole bound to 100.64.10.10 only

Useful verification commands

Check Headscale DNS:

1
grep -nA10 '^dns:' /home/ubuntu/headscale/config/config.yaml

Check Headscale nodes:

1
docker exec headscale headscale nodes list

You should see the Server node:

1
2
server-pihole
100.64.10.10

Check Pi-hole ports:

1
docker ps --filter name=pihole --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

Expected important ports:

1
2
3
4
127.0.0.1:53->53/tcp
127.0.0.1:53->53/udp
100.64.10.10:53->53/tcp
100.64.10.10:53->53/udp

Check Pi-hole blocking through the tailnet IP:

1
dig @100.64.10.10 netflix.com +short

Expected blocked response:

1
0.0.0.0

Check Pi-hole upstream:

1
docker exec pihole pihole-FTL --config dns.upstreams

Expected:

1
[ dnscrypt-proxy#53 ]

Check Authentik outpost route:

1
curl -kI https://pihole.example.com/outpost.goauthentik.io/ping

Expected:

1
HTTP/2 204

Check the protected web UI:

1
curl -kI https://pihole.example.com/admin/

Expected when not logged in:

1
2
HTTP/2 302
location: https://auth.example.com/application/o/authorize/...

Performance: making the phone connection faster

When the phone feels slow while connected to Headscale, separate the problem into two paths:

1
2
Phone ↔ Headscale/Tailscale path ↔ Server
Server ↔ public internet

If the Server has fast internet but the phone still feels slow, the issue is usually one of these:

  1. the phone is reaching the Server through a DERP relay instead of a direct WireGuard path;
  2. DNS requests are queueing because Pi-hole or dnscrypt-proxy is overloaded;
  3. the phone is still using an old DNS/VPN state and needs to reconnect.

Check if the phone is using DERP relay

From the Server, check the Tailscale/Headscale path to the phone:

1
2
tailscale status
tailscale ping --c 10 100.64.10.20

A slow path looks like this:

1
2
3
pong from phone-node via DERP(...) in 80ms
pong from phone-node via DERP(...) in 120ms
direct connection not established

DERP is Tailscale’s relay fallback. It is reliable, but slower than a direct peer-to-peer WireGuard path.

A faster path looks more like this:

1
pong from phone-node via 198.51.100.25:41641 in 20ms

The exact public address should be your own environment. Do not publish it in public docs.

Improve direct connectivity

To help Headscale/Tailscale avoid DERP relay:

  1. Make sure UDP is not blocked on the Server firewall.
  2. Allow the Tailscale WireGuard UDP port if your provider firewall supports it.
  3. Make sure the phone is not on a network that blocks UDP heavily.
  4. Reconnect the phone’s VPN after changing firewall/network settings.

Common firewall example:

1
sudo ufw allow 41641/udp

Cloud/provider firewall rule example:

1
2
Allow inbound UDP 41641 to the Server
Source: your trusted networks, or the provider's recommended Tailscale/Headscale policy

Then restart or reconnect Tailscale on both sides:

1
2
sudo systemctl restart tailscaled
tailscale ping --c 10 100.64.10.20

On Android:

1
Tailscale off → Tailscale on

If it still says via DERP, the phone’s current network may be forcing relay mode. Mobile networks and some public Wi-Fi networks often do this.

Keep DERP fallback stable

If direct peer-to-peer connectivity is not possible, Tailscale/Headscale uses DERP relay fallback. A relay path is slower than direct WireGuard, but it should still be stable with no packet loss.

In testing, forcing a local embedded DERP relay looked attractive at first, but it caused worse phone behavior on some networks:

1
2
3
4
Relay server unavailable
DNS unavailable
very high ping
packet loss

The reason is simple: if the phone cannot reliably reach the embedded DERP endpoint, Headscale may advertise a relay that the phone cannot actually use. In that case, public DERP fallback is safer.

The stable configuration is:

1
2
3
4
5
6
7
8
derp:
  server:
    enabled: false
    automatically_add_embedded_derp_region: false
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths: []
  auto_update_enabled: true

Then restart Headscale:

1
2
cd /home/ubuntu/headscale
docker compose restart headscale

Force clients to refresh their network map where possible:

1
sudo tailscale debug force-netmap-update

On the phone, reconnect the VPN:

1
Tailscale off → wait 5-10 seconds → Tailscale on

A healthy relayed path looks like this:

1
2
3
pong from phone-node via DERP(...) in 60ms
pong from phone-node via DERP(...) in 90ms
direct connection not established

That last line is not automatically a failure. It only means peer-to-peer UDP did not work and relay fallback was used. The important checks are:

  • no DNS warning;
  • no relay warning that stays after reconnect;
  • no packet loss;
  • reasonable latency for the phone network.

If a specific relay region shows a temporary warning, reconnect the phone first. Do not remove regions from the public DERP map unless you have tested the custom map format carefully; a broken DERP map can prevent Headscale from starting.

Allow tailnet clients to reach Pi-hole DNS

If the phone connects to Headscale but shows a DNS warning, verify that the Server firewall allows tailnet DNS traffic to Pi-hole.

The Server should allow DNS from the tailnet interface and tailnet range before any final reject rule:

1
2
3
4
sudo iptables -I INPUT 2 -i tailscale0 -p udp --dport 53 -j ACCEPT
sudo iptables -I INPUT 2 -i tailscale0 -p tcp --dport 53 -j ACCEPT
sudo iptables -I INPUT 2 -s 100.64.0.0/10 -p udp --dport 53 -j ACCEPT
sudo iptables -I INPUT 2 -s 100.64.0.0/10 -p tcp --dport 53 -j ACCEPT

Then verify DNS locally from the Server:

1
2
dig @100.64.10.10 google.com +tries=1 +time=2 +stats
dig @100.64.10.10 google.com +tcp +tries=1 +time=2 +stats

Healthy output shows NOERROR and low query time.

Check Server internet latency

The Server itself should have low latency to the internet:

1
2
3
ping -c 5 1.1.1.1
ping -c 5 8.8.8.8
ping -c 5 cloudflare.com

If these are fast but tailscale ping to the phone is slow, the bottleneck is the Headscale/Tailscale path, not the Server’s internet.

Check DNS latency through Pi-hole

DNS should be quick from the Server:

1
2
dig @100.64.10.10 google.com +tries=1 +time=2 +stats
dig @100.64.10.10 cloudflare.com +tries=1 +time=2 +stats

Healthy output usually shows low query times:

1
2
Query time: 3 msec
SERVER: 100.64.10.10#53

If query time is high or requests time out, inspect Pi-hole and dnscrypt-proxy.

Tune Pi-hole and dnscrypt-proxy for DNS bursts

Phones and browsers can generate many DNS queries quickly. A single page load can ask for the main domain, images, scripts, ads, analytics, fonts, API hosts, and tracking domains all at once.

In this setup, every client DNS request follows this path:

1
2
3
4
5
6
7
8
9
Phone
  ↓
Headscale tunnel
  ↓
Pi-hole
  ↓
dnscrypt-proxy
  ↓
Cloudflare DoH / Quad9 DoH

Pi-hole filters the request. dnscrypt-proxy sends the allowed request upstream using encrypted DNS.

If either Pi-hole or dnscrypt-proxy cannot handle the burst, the browser may look like it is stuck even though the internet connection is fine.

Look for warnings like:

1
2
Maximum number of concurrent DNS queries reached
Too many incoming connections

What they mean:

  • Maximum number of concurrent DNS queries reached means Pi-hole/dnsmasq has too many upstream DNS queries open at the same time.
  • Too many incoming connections means dnscrypt-proxy has more clients or pending requests than it is configured to accept.
  • Query time in dig output is how long one DNS answer took. Low values like 0-20 msec are good.
  • time_namelookup in curl output is the DNS part of opening a website. If this is high, DNS is the delay.
  • time_connect is the TCP connection time to the website.
  • time_appconnect is the TLS/HTTPS handshake time.
  • time_starttransfer or TTFB is how long until the website sends the first byte.
  • time_total is the full request time.

A useful timing command:

1
2
3
curl -L -o /dev/null -sS \
  -w 'code=%{http_code} dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
  --max-time 20 https://example.com

If dns is tiny but total is high, the website or network path is slow. If dns is high, fix DNS first.

dnscrypt-proxy settings

Edit:

1
2
cd /home/ubuntu/pihole
cp etc-dnscrypt-proxy/dnscrypt-proxy.toml etc-dnscrypt-proxy/dnscrypt-proxy.toml.bak

Recommended values for this setup:

1
2
3
4
5
6
7
8
9
10
max_clients = 20000
timeout = 1500
keepalive = 30

cache = true
cache_size = 32768
cache_min_ttl = 600
cache_max_ttl = 86400
cache_neg_min_ttl = 60
cache_neg_max_ttl = 600

What these do:

  • max_clients = 20000 raises how many incoming DNS requests dnscrypt-proxy can accept. This prevents bursty phone/browser traffic from being refused.
  • timeout = 1500 means upstream DNS requests should fail after 1.5 seconds instead of hanging for too long. This makes failures recover faster.
  • keepalive = 30 keeps upstream DoH connections open briefly, so dnscrypt-proxy does not need a fresh TLS connection for every DNS burst.
  • cache = true enables dnscrypt-proxy’s own cache.
  • cache_size = 32768 allows more cached DNS answers. More cache means fewer upstream DoH requests.
  • cache_min_ttl = 600 keeps successful DNS answers for at least 10 minutes, even if the upstream TTL is very small.
  • cache_max_ttl = 86400 allows cached answers to live up to one day when the domain TTL allows it.
  • cache_neg_min_ttl = 60 caches failed/nonexistent answers briefly, which avoids repeating the same bad query many times.
  • cache_neg_max_ttl = 600 caps negative-cache entries at 10 minutes.

This keeps DNSCrypt/DoH enabled while making bursts less likely to stall browsing.

Pi-hole forwarding limit

Add or update:

1
2
3
4
cat > dnsmasq.d/99-openclaw-dns-performance.conf <<'EOF'
# Allow bursts without overwhelming upstream DNS.
dns-forward-max=500
EOF

What this does:

  • dns-forward-max=500 allows Pi-hole/dnsmasq to have up to 500 outstanding upstream queries.
  • It should be high enough for browser and phone bursts.
  • It should not be set absurdly high, because then Pi-hole can flood dnscrypt-proxy with thousands of parallel requests.

Do not add cache-size in this custom file if Pi-hole already manages that option. Repeating dnsmasq keywords can stop Pi-hole from starting.

Restart the DNS services:

1
2
cd /home/ubuntu/pihole
docker compose restart dnscrypt-proxy pihole

Verify Pi-hole still uses dnscrypt-proxy:

1
docker exec pihole pihole-FTL --config dns.upstreams

Expected:

1
[ dnscrypt-proxy#53 ]

Verify SafeSearch and DoH behavior:

1
2
3
dig @100.64.10.10 www.google.com +short
dig @100.64.10.10 www.youtube.com +short
docker logs --since=2m dnscrypt-proxy | grep -Ei 'cloudflare|quad9|DoH|warning|error'

Healthy SafeSearch output looks like:

1
2
forcesafesearch.google.com.
restrict.youtube.com.

Healthy dnscrypt-proxy output includes a DoH resolver:

1
[cloudflare] OK (DoH)

A quick burst test:

1
2
3
4
for i in $(seq 1 200); do
  dig @100.64.10.10 example.com +tries=1 +time=3 +short >/dev/null &
done
wait

Then check logs again:

1
2
docker logs --since=2m pihole | grep -Ei 'Maximum|concurrent|warning|error'
docker logs --since=2m dnscrypt-proxy | grep -Ei 'Too many|warning|error|timeout'

Healthy result:

1
2
3
0 failed DNS burst queries
no fresh Too many incoming connections warnings
no fresh Maximum number of concurrent DNS queries warnings

Practical speed checklist

Use this order:

  1. Reconnect Tailscale/Headscale on the phone.
  2. Check tailscale ping --c 10 100.64.10.20 from the Server.
  3. If it says via DERP, improve UDP connectivity or try another phone network.
  4. Check DNS query time with dig @100.64.10.10.
  5. If DNS warnings appear, raise max_clients and dns-forward-max.
  6. Keep Pi-hole DNS private on 100.64.10.10; do not expose public port 53.

Troubleshooting

Phone still reaches blocked websites

Reconnect the VPN:

1
Tailscale off → Tailscale on

Then clear browser/app DNS cache or force close the app.

On Android, also check:

1
Settings → Network → Private DNS

If Private DNS is set to a provider like Google, Cloudflare, or AdGuard, it may bypass Pi-hole.

Pi-hole blocks on the server but not on the phone

Check what DNS Headscale is advertising:

1
grep -nA10 '^dns:' /home/ubuntu/headscale/config/config.yaml

It should show:

1
2
3
nameservers:
  global:
    - 100.64.10.10

Then reconnect the client.

Pi-hole ignores tailnet queries

Look for this in Pi-hole logs:

1
ignoring query from non-local network

Fix:

1
FTLCONF_dns_listeningMode: "all"

Then recreate Pi-hole:

1
2
cd /home/ubuntu/pihole
docker compose up -d --force-recreate pihole

The Pi-hole web UI does not redirect to Authentik

Check the Pi-hole Traefik label:

1
traefik.http.routers.pihole-secure.middlewares=authentik@docker

Check the Authentik outpost callback route:

1
Host(`pihole.example.com`) && PathPrefix(`/outpost.goauthentik.io/`)

Then recreate/restart the affected containers:

1
2
3
4
5
cd /home/ubuntu/pihole
docker compose up -d pihole

cd /home/ubuntu/authentik
docker compose up -d server

Final mental model

Remember it like this:

1
2
3
4
5
Headscale decides who is inside the private network.
Pi-hole decides which domains are allowed.
dnscrypt-proxy decides how allowed DNS leaves privately/encrypted.
Traefik decides how web traffic reaches services.
Authentik decides who can open the admin dashboards.

The Server joined Headscale because DNS filtering needs a private reachable DNS address for your phone.

That private address is:

1
100.64.10.10

Headscale gives that address to clients as their DNS server.

Pi-hole listens there.

dnscrypt-proxy handles encrypted upstream DNS.

Authentik protects the Pi-hole dashboard.

That is the whole system working together.

These posts connect to this topic and help build the bigger homelab picture:

This post is licensed under CC BY 4.0 by the author.