Infraestructura base y servicios centrales Base infrastructure and core services

Todo sigue montado sobre Debian, con IP fija en la red local (192.168.1.XXX). Ahí tengo corriendo Jellyfin (servidor de medios) y qBittorrent-nox (cliente de torrents con WebUI), con la misma configuración de almacenamiento y permisos que ya conté en el post 2:

  • Directorio nativo de descargas: /var/lib/qbittorrent/Downloads/.
  • Permisos de archivos: propiedad de usuario qbittorrent (chown -R qbittorrent:qbittorrent) y permisos 755/775.
  • Integración de grupos: usuario jellyfin añadido al grupo qbittorrent (usermod -aG qbittorrent jellyfin) para lectura directa sin conflictos de permisos.

Everything's still running on Debian, with a fixed IP on the local network (192.168.1.XXX). On it I have Jellyfin (media server) and qBittorrent-nox (torrent client with a WebUI) running, with the same storage and permissions setup I already covered in post 2:

  • Native download directory: /var/lib/qbittorrent/Downloads/.
  • File permissions: ownership by the qbittorrent user (chown -R qbittorrent:qbittorrent) with 755/775 permissions.
  • Group integration: the jellyfin user added to the qbittorrent group (usermod -aG qbittorrent jellyfin) for direct reading without permission conflicts.

Aislar el servicio y filtrar DNS (Docker + Pi-hole) Isolating the service and filtering DNS (Docker + Pi-hole)

Desplegué Pi-hole con Docker Compose usando network_mode: "host", para que reconociera de forma individual las IPs de la red local. La configuración vive en /root/pihole/docker-compose.yml.

Lo que dejé como válido en ese archivo, después de darle unas cuantas vueltas:

  • Variables de entorno modernas: FTLCONF_LOCAL_IPV4: '192.168.1.XXX' y FTLCONF_webserver_api_password para la contraseña de la WebUI.
  • DNS de respaldo (127.0.0.1 y 1.1.1.1) para garantizar resolución durante el arranque del contenedor.
  • Volúmenes persistentes en ./etc-pihole y ./etc-dnsmasq.d.

I deployed Pi-hole with Docker Compose using network_mode: "host", so it would recognize the local network's IPs individually. The config lives in /root/pihole/docker-compose.yml.

What I settled on as valid in that file, after going back and forth a few times:

  • Modern environment variables: FTLCONF_LOCAL_IPV4: '192.168.1.XXX' and FTLCONF_webserver_api_password for the WebUI password.
  • Fallback DNS (127.0.0.1 and 1.1.1.1) to guarantee resolution while the container boots.
  • Persistent volumes at ./etc-pihole and ./etc-dnsmasq.d.

El registro de incidencias The incident log

Como siempre digo, esto nunca sale a la primera. Voy a dejar aquí anotados los dos líos que me dieron más guerra, porque seguro que a alguien más le pasa lo mismo.

Puerto 53 / systemd-resolved

La incidencia: al abrir /etc/systemd/resolved.conf con nano, el archivo directamente no existía. Antes de dar nada por sentado comprobé si el puerto 53 estaba ocupado:

ss -tulpn | grep :53

El diagnóstico fue que el puerto 53 estaba completamente libre por defecto en mi Debian base, así que al final no hizo falta tocar nada de systemd-resolved. A veces el problema no está donde uno piensa.

Estado "unhealthy" y fallo de autenticación en Pi-hole

La incidencia: el contenedor se quedaba en estado unhealthy y el panel web me devolvía "Wrong password!" y "DNS Server failure detected".

La causa raíz, después de revisar línea por línea, eran dos cosas a la vez: tenía comentarios (#) pegados en la misma línea que las variables de entorno y de DNS dentro del docker-compose.yml, y encima estaba usando la variable heredada WEBPASSWORD en lugar del estándar moderno de la API.

La solución fue limpiar el YAML entero quitando los comentarios en línea, pasar a FTLCONF_webserver_api_password, y de paso cambiar la contraseña en caliente directamente desde el contenedor:

docker exec -it pihole pihole -a -p 'TU_CONTRASEÑA'

As always, this never works on the first try. I'm leaving the two issues that gave me the most trouble noted here, because I'm sure someone else will run into the same thing.

Port 53 / systemd-resolved: the issue was that opening /etc/systemd/resolved.conf with nano, the file simply didn't exist. Before assuming anything I checked whether port 53 was in use with the command above. The diagnosis was that port 53 was completely free by default on my base Debian, so in the end there was nothing to touch in systemd-resolved. Sometimes the problem isn't where you think it is.

"Unhealthy" status and authentication failure in Pi-hole: the container stayed in unhealthy status and the web panel returned "Wrong password!" and "DNS Server failure detected". The root cause, after going through it line by line, was two things at once: I had comments (#) stuck on the same line as the environment and DNS variables inside docker-compose.yml, and on top of that I was using the legacy WEBPASSWORD variable instead of the modern API standard. The fix was cleaning up the whole YAML file by removing the inline comments, switching to FTLCONF_webserver_api_password, and changing the password on the fly directly from the container with the command above.

Red privada remota y cortafuegos Remote private network and firewall

Sigo usando Tailscale (mesh VPN) sobre la interfaz tailscale0 (rango 100.X.X.X) para tener acceso remoto cifrado sin depender de abrir puertos en el router, esquivando la CG-NAT de mi ISP. Y el mantenimiento sigue automatizado con unattended-upgrades, apt-listchanges y temporizadores de systemd persistentes, tal y como conté en el post 2.

Lo que sí actualicé fue el cortafuegos de nftables, para dejar entrar exactamente lo que necesita Pi-hole y nada más, con política de entrada drop por defecto:

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

        # Tráfico de loopback
        iif "lo" accept

        # Conexiones ya establecidas o relacionadas
        ct state established,related accept

        # Acceso total sobre la interfaz de Tailscale
        iifname "tailscale0" accept

        # Tráfico desde la subred local de casa
        ip saddr 192.168.1.0/24 accept

        # Puertos específicos habilitados para la LAN
        ip saddr 192.168.1.0/24 udp dport 53 accept   # DNS (Pi-hole)
        ip saddr 192.168.1.0/24 tcp dport 53 accept   # DNS (Pi-hole)
        ip saddr 192.168.1.0/24 tcp dport 80 accept   # WebUI Pi-hole
        ip saddr 192.168.1.0/24 tcp dport 8080 accept # qBittorrent
        ip saddr 192.168.1.0/24 tcp dport 8096 accept # Jellyfin
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

I'm still using Tailscale (mesh VPN) over the tailscale0 interface (range 100.X.X.X) for encrypted remote access without relying on opening ports on the router, getting around my ISP's CG-NAT. And maintenance is still automated with unattended-upgrades, apt-listchanges, and persistent systemd timers, as I already covered in post 2.

What I did update was the nftables firewall, to let in exactly what Pi-hole needs and nothing more, with a default-deny inbound policy, as shown above.

Cómo apuntar un dispositivo a Pi-hole How to point a device at Pi-hole

Estado: comprobado y operativo. El filtrado de anuncios y la resolución DNS funcionan correctamente. Elegí configurarlo dispositivo a dispositivo en lugar de tocar el router entero, así que estos son los pasos que sigo cada vez:

  • Windows: Configuración de red → Editar asignación de servidor DNS → Manual (IPv4) → servidor DNS preferido: 192.168.1.XXX.
  • Android / iOS: Ajustes de Wi-Fi → ajustes IP de la red local → cambiar a IP estática → asignar como DNS 1 la dirección 192.168.1.XXX.
  • Verificación: entro en la interfaz de administración (http://192.168.1.XXX/admin) y compruebo que suben en tiempo real las métricas de "Queries Blocked".

Status: checked and operational. Ad filtering and DNS resolution work correctly. I chose to configure it device by device instead of touching the whole router, so these are the steps I follow every time:

  • Windows: Network settings → Edit DNS server assignment → Manual (IPv4) → preferred DNS server: 192.168.1.XXX.
  • Android / iOS: Wi-Fi settings → local network IP settings → switch to static IP → set DNS 1 to 192.168.1.XXX.
  • Verification: I go into the admin interface (http://192.168.1.XXX/admin) and check that the "Queries Blocked" metric climbs in real time.