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.
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: trueenables Headscale/Tailscale DNS features.base_domain: tail.example.comis the internal tailnet DNS suffix.override_local_dns: truetells clients to use Headscale-provided DNS instead of their local network DNS.100.64.10.10is 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:
- The phone has not reconnected to Headscale after the DNS change.
- The phone cached DNS before the block was added.
- The Netflix app cached resolved IPs.
- Android/iOS private DNS or browser DoH is bypassing system DNS.
- 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:
- the phone is reaching the Server through a DERP relay instead of a direct WireGuard path;
- DNS requests are queueing because Pi-hole or dnscrypt-proxy is overloaded;
- 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:
- Make sure UDP is not blocked on the Server firewall.
- Allow the Tailscale WireGuard UDP port if your provider firewall supports it.
- Make sure the phone is not on a network that blocks UDP heavily.
- 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 reachedmeans Pi-hole/dnsmasq has too many upstream DNS queries open at the same time.Too many incoming connectionsmeans dnscrypt-proxy has more clients or pending requests than it is configured to accept.Query timeindigoutput is how long one DNS answer took. Low values like0-20 msecare good.time_namelookupincurloutput is the DNS part of opening a website. If this is high, DNS is the delay.time_connectis the TCP connection time to the website.time_appconnectis the TLS/HTTPS handshake time.time_starttransferor TTFB is how long until the website sends the first byte.time_totalis 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 = 20000raises how many incoming DNS requests dnscrypt-proxy can accept. This prevents bursty phone/browser traffic from being refused.timeout = 1500means upstream DNS requests should fail after 1.5 seconds instead of hanging for too long. This makes failures recover faster.keepalive = 30keeps upstream DoH connections open briefly, so dnscrypt-proxy does not need a fresh TLS connection for every DNS burst.cache = trueenables dnscrypt-proxy’s own cache.cache_size = 32768allows more cached DNS answers. More cache means fewer upstream DoH requests.cache_min_ttl = 600keeps successful DNS answers for at least 10 minutes, even if the upstream TTL is very small.cache_max_ttl = 86400allows cached answers to live up to one day when the domain TTL allows it.cache_neg_min_ttl = 60caches failed/nonexistent answers briefly, which avoids repeating the same bad query many times.cache_neg_max_ttl = 600caps 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=500allows 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:
- Reconnect Tailscale/Headscale on the phone.
- Check
tailscale ping --c 10 100.64.10.20from the Server. - If it says
via DERP, improve UDP connectivity or try another phone network. - Check DNS query time with
dig @100.64.10.10. - If DNS warnings appear, raise
max_clientsanddns-forward-max. - Keep Pi-hole DNS private on
100.64.10.10; do not expose public port53.
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.
Related documentation
These posts connect to this topic and help build the bigger homelab picture:
- Self-host Headscale with a protected web UI — explains the Headscale control plane that makes the private DNS path possible.
- Build a homelab auth gateway with Traefik and Authentik — explains the Authentik and Traefik pattern used for the Pi-hole dashboard.
- Self-host OpenClaw with Docker, Traefik, Authentik, and Telegram — uses the same protected gateway ideas for an AI service.