En la Parte 1 configuré Headscale e hice que todos los clientes de tailnet usaran Cloudflare DNS. Funcionó, pero podía ser mejor: cada dispositivo que se conecta a mi tailnet obtiene los servidores DNS que yo configure, así que bien podría ejecutar mi propio servidor DNS dentro de la red privada y conseguir bloqueo de anuncios, bloqueo de rastreadores y resolución de nombres interna esté donde esté, de forma transparente y cifrada.
En este artículo cambiaré Cloudflare por Blocky, un proxy DNS ligero con soporte incorporado para listas de bloqueo.
Elegí Blocky en lugar de Pi-hole o AdGuard Home porque es más simple, se configura en un único archivo YAML. No intenta ser un producto web completo, solo un servidor DNS que hace su trabajo y no molesta.
Podrías reemplazar fácilmente Blocky con algún otro servidor o proxy DNS de tu elección manteniendo las partes de Headscale y Tailscale.
Objetivos
- Bloquear anuncios y rastreadores a nivel de DNS en cada dispositivo con Tailscale.
- Usar DNS cifrado hacia los DNS globales (DoT ascendente), aunque el tráfico entre mis dispositivos y Blocky ya esté protegido por WireGuard. Eso evitará el espionaje de mi ISP.
- Tener resolución de nombres interna, que, por ejemplo,
homeserver.ts.fidelramos.netsiga funcionando. - Sin configuración de DNS a nivel de dispositivo. Headscale envía la configuración y los clientes la usan automáticamente.
¿Por qué no usar solo el DNS privado de Android?
Android tiene una función incorporada de "DNS privado" que hace DoT a cualquier servicio de resolución público.
Anteriormente estaba usando un servidor DNS privado de AdGuard (xxxxxxxx.d.adguard-dns.com).
Intenté usarlo junto con Tailscale, pero no funciona: tan pronto como Tailscale envía cualquier configuración de DNS al cliente, Android enruta el DNS a través del túnel a través del resolvedor interno de Tailscale (100.100.100.100), que no habla DoT.
La sonda de DNS privado de Android falla con "no se puede conectar" y la resolución de DNS se rompe en todo el dispositivo.
Puedes deshabilitar "Usar DNS de Tailscale" en la aplicación para recuperar el DNS privado, pero entonces pierdes MagicDNS.
Forzado a elegir, fui por el otro camino: ejecutar mi propio DNS en la tailnet y hacer que Headscale lo envíe a todos los clientes.
Arquitectura
Dispositivo en la tailnet
│
│ DNS sin cifrar (dentro de túnel WireGuard)
▼
Blocky ──▶ listas de bloqueo (local)
│
│ DNS-over-TLS (DoT) a DNS global
▼
Cloudflare / Quad9 / Google DNS / etc.
- Los clientes se comunican por DNS sin cifrar con la IP de tailnet de Blocky. No hace falta cifrado porque la ruta completa va por un túnel WireGuard.
- Blocky realiza el filtrado contra las listas de bloqueo que mantiene localmente.
- Todo lo que no está bloqueado se resuelve por servicios DNS globales que Blocky contacta a través de DoT.
- Blocky asociará algunos dominios locales de la tailnet a IPs internas.
Blocky en Docker Compose
Ejecuto Blocky en el mismo proyecto Docker Compose donde están todos los otros servicios de mi servidor doméstico.
compose.yml:
services:
blocky:
image: spx01/blocky:latest
container_name: blocky
network_mode: host
restart: unless-stopped
volumes:
- ${BLOCKY_ROOT}/config.yml:/app/config.yml:ro
- /var/log/blocky:/logs:rw
environment:
- TZ=${TZ}
healthcheck:
test: ["CMD", "/app/blocky", "healthcheck"]
interval: 1m
timeout: 10s
retries: 3
Puntos a destacar:
- network_mode: host. Esto es un poco perezoso por mi parte, pero abrir el puerto Docker solo a la IP de tailnet es más complicado de lo que parece. Blocky se configurará en la siguiente sección para que solo escuche en la interfaz de Tailscale, por lo que no creo que sea un gran riesgo de seguridad. Confío en que Blocky no vaya a escuchar en otras interfaces o puertos del sistema.
- El archivo de configuración es de solo lectura dentro del contenedor. Es más seguro y me recuerda que el origen es mi configuración versionada en disco, y no lo que está en el contenedor.
- Tuve que hacer
chown 100 /var/log/blocky, ya que ese es el UID que usa el contenedor de blocky. Antes de hacerlo los registros de Docker de Blocky mostraban errores de permiso de escritura.
Configuración de Blocky
Esta es mi configuración con los nombres internos redactados. Consulta la documentación de Blocky para todos los detalles.
/var/opt/blocky/config/config.yml
# =================================================================
# Blocky config — Tailscale/headscale global resolver
# =================================================================
# ---------------------------------------------------------------
# Upstream resolvers (encrypted via DoT)
# ---------------------------------------------------------------
upstreams:
init:
strategy: blocking
groups:
default:
- tcp-tls:dns.quad9.net:853
- tcp-tls:one.one.one.one:853
# Explicit IPs for upstream resolution at startup
# (avoids chicken-and-egg when Blocky is itself the system resolver)
bootstrapDns:
- upstream: tcp-tls:1.1.1.1:853
ips:
- 1.1.1.1
- 1.0.0.1
- upstream: tcp-tls:9.9.9.9:853
ips:
- 9.9.9.9
- 149.112.112.112
# ---------------------------------------------------------------
# Client identification (static, since no DHCP on tailnet)
# Add a new entry here whenever you add a tailscale node
# ---------------------------------------------------------------
clientLookup:
clients:
homeserver:
- 100.64.0.1
laptop:
- 100.64.0.2
phone:
- 100.64.0.3
desktop:
- 100.64.0.4
# ---------------------------------------------------------------
# Custom local DNS records for your tailnet nodes
# Add a new entry here whenever you add a tailscale node
# ---------------------------------------------------------------
customDNS:
customTTL: 1h
filterUnmappedTypes: true
rewrite:
ts.fidelramos.net: tail # allows 'X.ts.fidelramos.net' as shortcut for 'X.tail'
mapping:
homeserver.tail: 100.64.0.1
laptop.tail: 100.64.0.2
phone.tail: 100.64.0.3
desktop.tail: 100.64.0.4
# Friendly names for services on tailnet (for Part 4)
immich.tail: 100.64.0.1
jellyfin.tail: 100.64.0.1
nextcloud.tail: 100.64.0.1
# ---------------------------------------------------------------
# Blocking
# ---------------------------------------------------------------
blocking:
denylists:
ads:
- https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt
- 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/Easyprivacy.txt
malware:
- https://urlhaus.abuse.ch/downloads/hostfile/
- https://v.firebog.net/hosts/Prigent-Malware.txt
tracking:
- https://v.firebog.net/hosts/Easyprivacy.txt
smart-tv:
- https://perflyst.github.io/PiHoleBlocklist/SmartTV.txt
kids:
- https://blocklistproject.github.io/Lists/porn.txt
- https://blocklistproject.github.io/Lists/gambling.txt
allowlists:
ads:
- |
# Common false positives
clients4.google.com
clients2.google.com
googleads.g.doubleclick.net
clientGroupsBlock:
default:
- ads
- malware
- tracking
# Per-client overrides by name (from clientLookup above)
# phone:
# - ads
# - malware
# - tracking
# - smart-tv
# kids-tablet:
# - ads
# - malware
# - kids
loading:
refreshPeriod: 24h
downloads:
timeout: 60s
attempts: 3
cooldown: 10s
strategy: blocking
maxErrorsPerSource: 5
blockType: zeroIp
blockTTL: 1m
# ---------------------------------------------------------------
# Caching
# ---------------------------------------------------------------
caching:
minTime: 5m
maxTime: 30m
maxItemsCount: 0
prefetching: true
prefetchExpires: 2h
prefetchThreshold: 5
cacheTimeNegative: 30m
# ---------------------------------------------------------------
# Ports & binding
# Bind ONLY to tailnet IP so Blocky isn't exposed on LAN/WAN
# ---------------------------------------------------------------
ports:
dns: 100.64.0.1:53
tls: 100.64.0.1:853 # optional DoT for tailnet clients
http: 127.0.0.1:4000 # metrics / API (local only)
# ---------------------------------------------------------------
# Logging & metrics
# ---------------------------------------------------------------
log:
level: info
format: text
timestamp: true
privacy: false
queryLog:
type: csv-client
target: /logs
logRetentionDays: 7
creationAttempts: 3
creationCooldown: 2s
prometheus:
enable: false
path: /metrics
# ---------------------------------------------------------------
# Misc
# ---------------------------------------------------------------
# Block leaks of private-use TLDs to public resolvers
specialUseDomains:
rfc6762-appendixG: true
A grandes rasgos:
- DNS global sobre DoT: Cloudflare y Quad9 como servicios de resolución global, en paralelo, usando DNS-over-TLS para que el tráfico proveniente de Blocky esté cifrado.
- Entradas DNS personalizadas: algunos nombres internos que quiero resolver en toda la tailnet sin tocar los
extra_recordsde Headscale (esto lo cubriré en la Parte 4). - Bloqueo: una mezcla de listas generales y algunas listas de bloqueo específicas que me interesan. Iré ajustándolas, pero por ahora son un buen punto de partida.
- Caché: habilitado, con valores predeterminados sensatos.
- Métricas de Prometheus: desactivadas por ahora, no tengo un recolector de métricas en este momento, lo haré en mi nuevo servidor doméstico.
Arrancarlo:
$ sudo docker compose up -d
$ sudo docker logs -f blocky
Verificación desde el host:
La resolución por DNS funciona:
$ dig @100.64.0.1 example.com
; <<>> DiG 9.20.22 <<>> @100.64.0.1 example.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 57184
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 300 IN A 172.66.147.243
example.com. 300 IN A 104.20.23.154
;; Query time: 24 msec
;; SERVER: 100.64.0.1#53(100.64.0.1) (UDP)
;; WHEN: Sun May 10 16:30:57 WEST 2026
;; MSG SIZE rcvd: 72
Dominio bloqueado (devuelve 0.0.0.0):
$ dig @100.64.0.1 doubleclick.net
; <<>> DiG 9.20.22 <<>> @100.64.0.1 doubleclick.net
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 45469
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;doubleclick.net. IN A
;; ANSWER SECTION:
doubleclick.net. 60 IN A 0.0.0.0
;; Query time: 1 msec
;; SERVER: 100.64.0.1#53(100.64.0.1) (UDP)
;; WHEN: Sun May 10 16:32:50 WEST 2026
;; MSG SIZE rcvd: 49
Blocky solo se enlaza a la IP de tailnet, no hay puerto DNS expuesto a Internet:
$ sudo ss -ulnp | grep :53
UNCONN 0 0 100.64.0.1:53 0.0.0.0:* users:(("blocky",pid=2984612,fd=15))
$ sudo ss -tlnp | grep -E ':53|:853'
LISTEN 0 4096 100.64.0.1:53 0.0.0.0:* users:(("blocky",pid=2984612,fd=16))
LISTEN 0 4096 100.64.0.1:853 0.0.0.0:* users:(("blocky",pid=2984612,fd=14))
Cambios en Headscale
Ahora, la magia de Tailscale: le digo a Headscale que envíe la IP de tailnet de Blocky como DNS global a cada cliente.
Edita /var/opt/headscale/config/config.yaml, su bloque dns cambia de la siguiente manera:
dns:
magic_dns: false
override_local_dns: true # que los clientes usen nuestro DNS, reemplazando su DNS del sistema
base_domain: ts.fidelramos.net
nameservers:
global:
- 100.64.0.1 # Blocky en la tailnet
search_domains: []
Reinicia Headscale:
sudo docker compose restart headscale
Cada cliente tiene que obtener la nueva configuración:
- Linux:
sudo tailscale up --login-server=https://headscale.fidelramos.net --accept-dns=true
- Android: desconecta y vuelve a conectar Tailscale. También deshabilita el DNS privado de Android (Ajustes → Red → DNS privado → Desactivado), ya que entraría en conflicto.
Verifica desde un cliente Linux:
$ resolvectl status | grep -A2 tailscale0
# Debería mostrar 100.64.0.1 como el servidor DNS en tailscale0
$ dig example.com
# Debería resolver a través de Blocky
Por qué es mejor que DoT en cada dispositivo
El cifrado es o igual o mejor:
- Dispositivo → Blocky: DNS sin cifrar, pero dentro de un túnel WireGuard. El ISP ve un flujo UDP al servidor doméstico, pero nada más.
- Blocky → DNS global: DNS-over-TLS cifrado. El DNS global ve la consulta del servidor, pero no qué dispositivo original la hizo.
Y hay beneficios que no tendría con sólo DoT en cada dispositivo:
- Autoconfiguración, sólo con conectarse por Tailscale.
- Listas de bloqueo centralizadas. Cambio un archivo y todos los dispositivos se benefician.
- Registros de consultas por cliente en un solo lugar. Blocky puede registrar consultas por IP de cliente.
- Entradas DNS internas, las usaré en la Parte 4.
- Funciona idénticamente en todas las plataformas, sin el baile del DNS privado de Android.
- No es necesario cambiar la configuración del router. Mi router actual no me permite cambiar los DNS, así que esta es una forma ingeniosa de configurar el DNS en todos mis dispositivos sin tener que ir uno por uno.
Inconvenientes encontrados
Android "no se puede conectar" con el DNS privado configurado
Ya lo mencioné en la Parte 1, pero vale la pena repetirlo: con el DNS de Tailscale activo, la configuración de DNS privado de Android debe estar Desactivada o Automática. Configurar un nombre de host DoT estricto rompe el DNS por completo en el dispositivo mientras Tailscale está conectado.
La resolución de DNS se rompe en el servidor
Si el servidor usa 127.0.0.53 (systemd-resolved) y luego le pides que use Blocky para todo, y Blocky está en un contenedor que depende de DNS para iniciarse... puedes meterte en un callejón sin salida.
Al menos dos formas de evitarlo:
- Deja el
/etc/resolv.confdel host apuntando a un servicio de resolución externo (por ejemplo, Cloudflare) y solo envía Blocky a los clientes de tailnet. Esto es lo que yo hago: el propio host no usa Blocky, solo los dispositivos que se conectan a través de Tailscale. - O, fija la imagen de Blocky para que siempre esté disponible localmente y deja que Docker la inicie antes de que cualquier otra cosa necesite DNS.
Los clientes no usan el nuevo DNS
Headscale envía la configuración de DNS en cada actualización del mapa, pero algunos clientes (especialmente Linux con NetworkManager) necesitan un empujón.
Ejecutar tailscale up de nuevo con --accept-dns=true fuerza una resincronización.
En Android, apagar y encender suele ser suficiente.
Hay que deshabilitar MagicDNS
Si magic_dns se hubiera dejado habilitado en la configuración de Headscale, habría entrado en conflicto con Blocky DNS, ya que ambos intentarían usar el mismo puerto 53.
Decidí deshabilitar MagicDNS y mantener mapping en la configuración de Blocky de dominios e IPs.
Es un proceso manual, pero mi tailnet es pequeña y no cambiará mucho, así que no me importa si eso implica una configuración más sencilla.
Una solución alternativa sería ejecutar el MagicDNS de Headscale en un puerto diferente y luego hacer que Blocky lo use.
Qué sigue
Con Blocky así configurado, cada dispositivo en mi tailnet tiene bloqueo de anuncios y DNS filtrado, a través de cualquier conexión a Internet en cualquier parte del mundo.
En la Parte 3 reconfiguraré Syncthing para usar exclusivamente la tailnet, deshabilitando todas sus funciones de descubrimiento y retransmisión orientadas a la WAN ahora que son innecesarias.