Post

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:

  1. Go to DNS → Global nameservers
  2. Add the container’s Tailscale IP (e.g., 100.70.41.60)
  3. Enable Override local DNS
  4. 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.

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=false in environment
  • --snat-subnet-routes=false in TS_EXTRA_ARGS
  • The 99-tailscale.conf dnsmasq config with bind-interfaces and except-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 -4 output 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:

  1. Shared network namespace (network_mode: service:tailscale) eliminates complexity
  2. Kernel networking (TS_USERSPACE=false) preserves real client IPs
  3. Tailscale Serve provides HTTPS with valid certs automatically
  4. Bypass groups allow selective unfiltering without separate infrastructure
  5. 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.


Resources

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