Running Pi-hole as a Tailscale Node with Docker
I wanted network-wide ad blocking that follows my Apple devices everywhere—at home, on LTE, at coffee shops, travelling. My Unifi router already had ad blocking for my home network, but the moment I switched to cellular data, ads came back. The solution: Pi-hole running as a Tailscale node, providing DNS filtering to all my tailnet devices regardless of location.
Architecture Overview
The final setup looks like this:
- Pi-hole + Tailscale in Docker with shared network namespace
- Pi-hole acts as Global DNS for the entire Tailscale network
- Mobile devices always connected to Tailscale = always ad-filtered
- Bypass group for specific clients that need unfiltered DNS
- HTTPS admin UI via Tailscale Serve with valid certificates
Prerequisites
- Docker and Docker Compose installed
- Tailscale account with admin access
- Basic understanding of DNS and Docker networking
Docker Compose Setup
Final Working Configuration
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
27
28
29
30
31
32
33
34
35
36
37
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
hostname: pihole-tailscale
restart: unless-stopped
cap_add:
- NET_ADMIN
- NET_RAW
- SYS_MODULE
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- /path/to/Data/pi_ts/state:/var/lib/tailscale
- /path/to/Data/pi_ts/config:/config
environment:
- TS_AUTHKEY=tskey-auth-YOUR-KEY-HERE
- TS_STATE_DIR=/var/lib/tailscale
- TS_SERVE_CONFIG=/config/serve.json
- TS_USERSPACE=false
- TS_EXTRA_ARGS=--accept-dns=false --snat-subnet-routes=false
pihole:
image: pihole/pihole:latest
container_name: pihole_ts
restart: unless-stopped
depends_on:
- tailscale
network_mode: "service:tailscale"
environment:
TZ: "America/Montreal"
WEBPASSWORD: "your-secure-password"
DNSMASQ_LISTENING: "all"
PIHOLE_DNS_: "1.1.1.1;1.0.0.1"
volumes:
- /path/to/Data/pihole1/etc/pihole:/etc/pihole
- /path/to/Data/pihole1/etc/dnsmasq.d:/etc/dnsmasq.d
Key Configuration Decisions
network_mode: "service:tailscale" - This is the magic. Pi-hole shares Tailscale’s network namespace entirely, meaning:
- Pi-hole listens directly on the Tailscale IP (
100.x.x.x:53) - DNS queries hit the Tailscale node’s IP directly
- No port forwarding or bridging needed
TS_USERSPACE=false - Switches Tailscale to kernel networking mode, allowing Pi-hole to see real client IPs instead of everything appearing as localhost.
--snat-subnet-routes=false - Prevents Tailscale from masquerading client IPs, preserving the original 100.x.x.x addresses in Pi-hole’s query log.
--accept-dns=false - Prevents the Tailscale container from using the tailnet’s Global DNS (which will be itself), avoiding DNS loops.
Critical DNS Configuration
Fix for “localhost” Client IPs
Create a custom dnsmasq config to prevent all queries appearing as localhost:
1
2
3
4
cat << 'EOF' > /path/to/Data/pihole1/etc/dnsmasq.d/99-tailscale.conf
bind-interfaces
except-interface=lo
EOF
This tells dnsmasq to bind to real interfaces only and ignore loopback, so queries show real client Tailscale IPs.
Tailscale Configuration
1. Set Global DNS
Get the Tailscale container’s IP:
1
2
docker exec -it tailscale tailscale ip -4
# Example output: 100.70.41.60
In Tailscale admin console:
- Go to DNS → Global nameservers
- Add the container’s Tailscale IP (e.g.,
100.70.41.60) - Enable Override local DNS
- Enable MagicDNS (optional but recommended)
2. Configure Conditional Forwarding
In Pi-hole admin UI under Settings → DNS → Conditional Forwarding:
Add this single line in the text box:
1
true,100.64.0.0/10,100.100.100.100,tailscale.ts.net
Replace tailscale.ts.net with your actual tailnet name. This allows Pi-hole to resolve Tailscale hostnames, showing machine-123.tailscale.ts.net instead of raw 100.x.x.x IPs in query logs.
HTTPS Admin Access with Tailscale Serve
Create Serve Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mkdir -p /path/to/Data/pi_ts/config
cat << 'EOF' > /path/to/Data/pi_ts/config/serve.json
{
"TCP": {
"443": {
"HTTPS": true
}
},
"Web": {
"${TS_CERT_DOMAIN}:443": {
"Handlers": {
"/": {
"Proxy": "http://localhost:80"
}
}
}
}
}
EOF
After restarting the containers, access the admin UI via:
1
https://pihole-tailscale.tailscale.ts.net/admin
Tailscale automatically issues a valid certificate—no self-signed warnings.
Creating a Bypass Group
For clients that need unfiltered DNS (like a gaming PC or development machine):
1. Create the Group
Group Management → Groups:
- Name:
bypass - Description:
No blocking - Do not assign any blocklists to this group
2. Add Client
Group Management → Clients:
- Enter the client’s Tailscale IP or hostname (e.g.,
machine-123.tailscale.ts.net) - Click Add
3. Assign to Bypass Group
In the Clients list:
- Find the client
- Click the Groups dropdown
- Uncheck
Default - Check
bypass
The client will now resolve all DNS normally without any filtering.
Recommended Blocklists
After extensive testing, here’s my curated list of 8 high-quality blocklists:
1
2
3
4
5
6
7
8
https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
https://adaway.org/hosts.txt
https://v.firebog.net/hosts/AdguardDNS.txt
https://v.firebog.net/hosts/Easylist.txt
https://pgl.yoyo.org/adservers/serverlist.php?hostformat=hosts&showintro=0&mimetype=plaintext
https://v.firebog.net/hosts/Easyprivacy.txt
https://v.firebog.net/hosts/Prigent-Ads.txt
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.plus.txt
The Hagezi pro.plus.txt list includes cryptojacking protection and is one of the most comprehensive single blocklists available.
Security Hardening
Tailscale ACLs
Restrict admin UI access to only your devices:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"tagOwners": {
"tag:dns-server": ["your-email@example.com"]
},
"acls": [
{
"action": "accept",
"src": ["your-email@example.com"],
"dst": ["*:*"]
},
{
"action": "accept",
"src": ["*"],
"dst": ["tag:dns-server:53"]
}
]
}
Then tag the pihole-tailscale node with tag:dns-server in the Tailscale admin console.
This allows everyone to use DNS (port 53) but only you can access the admin UI (ports 80/443).
Verification and Testing
Test DNS Resolution
From any tailnet device:
1
nslookup google.com 100.70.41.60
Should resolve quickly and appear in Pi-hole’s query log with the client’s real Tailscale hostname.
Verify Blocking
Query a known blocked domain:
1
nslookup doubleclick.net 100.70.41.60
Should return 0.0.0.0 or similar, with a blocked status in the query log.
Check Query Log
In Pi-hole admin under Queries, you should see:
- Real client hostnames (e.g.,
iphone.tailscale.ts.net) - Green ∞ icons for allowed queries
- Red ⊘ icons for blocked queries
Common Issues and Solutions
“localhost” appears as client
Problem: All queries show 127.0.0.1 as the client.
Solution: Verify you have:
TS_USERSPACE=falsein environment--snat-subnet-routes=falseinTS_EXTRA_ARGS- The
99-tailscale.confdnsmasq config withbind-interfacesandexcept-interface=lo
DNS queries time out
Problem: nslookup hangs with “connection timed out”.
Solution: Confirm:
- Global DNS IP matches
docker exec -it tailscale tailscale ip -4output exactly - Pi-hole container is healthy:
docker exec -it pihole_ts pihole status - Test from inside Tailscale container:
docker exec -it tailscale nslookup google.com localhost
“SPECIAL_DOMAIN” blocks in query log
Not a problem: These are hardcoded Pi-hole protections for domains like mask-h2.icloud.com (iCloud Private Relay) that would bypass Pi-hole DNS. This is expected and correct behavior.
Performance and Reliability
Docker Health Check
Add this to the pihole service for automatic restart on failures:
1
2
3
4
5
6
healthcheck:
test: ["CMD", "dig", "+norecurse", "+retry=0", "@127.0.0.1", "pi.hole"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
DNS Failover
For production reliability, consider:
- Running a second Pi-hole on a different host
- Adding both Tailscale IPs as Global nameservers
- Accepting ~30 second failover time if primary host goes down
Do not add 1.1.1.1 as a second nameserver—Tailscale may round-robin between them, bypassing Pi-hole filtering unpredictably.
Final Architecture
1
2
3
4
5
6
7
8
9
Mobile device (away from home)
↓
Tailscale VPN (always connected)
↓
Pi-hole on Tailscale node (100.x.x.x:53)
↓
Cloudflare DNS (1.1.1.1)
↓
Internet (filtered)
At home, LAN devices continue using the Unifi router’s ad blocking. Tailscale-connected Apple devices (iPhone, iPad, Mac) use Pi-hole whether at home or away, achieving consistent ad filtering everywhere.
Conclusion
This setup solves the “losing ad blocking on cellular” problem completely. The key insights:
- Shared network namespace (
network_mode: service:tailscale) eliminates complexity - Kernel networking (
TS_USERSPACE=false) preserves real client IPs - Tailscale Serve provides HTTPS with valid certs automatically
- Bypass groups allow selective unfiltering without separate infrastructure
- Tailscale ACLs restrict admin access while allowing global DNS usage
The entire setup runs in two Docker containers. No port forwarding, no VPS needed, no complex networking—just clean, maintainable infrastructure that works.
