Problema

En muchos homelabs los servicios expuestos al exterior se acceden mediante un subdominio gestionado por un proveedor DNS externo (por ejemplo app.my.domain). Cuando el mismo subdominio se resuelve dentro de la red local, el tráfico suele salir a Internet y volver a entrar, lo que genera latencia innecesaria y, sobre todo, problemas de TLS: el certificado emitido para la IP pública no coincide con la IP interna y aplicaciones como Jellyfin rechazan la conexión. El patrón típico es:

  • Un reverse proxy (Traefik) escucha en 80/443 en la LAN.
  • Un DNS recursivo interno (AdGuard, Pi‑hole, etc.) devuelve la IP local para *.app.my.domain.
  • Los contenedores están en la misma red Docker que Traefik y usan etiquetas para la ruta.
  • El certificado se gestiona con ACME a través del DNS del proveedor externo (Hetzner, Cloudflare, etc.).

El síntoma más frecuente es “HTTPS error: certificado no válido” o “la aplicación no reconoce la URL”. La raíz del problema suele estar en la forma en que se solicitan los certificados y en la separación entre DNS externo e interno.

Causa

  1. Desacuerdo entre DNS y ACME
    El desafío DNS‑01 necesita que el registro _acme-challenge apunte al proveedor que controla la zona pública. Si el dominio está delegada a un servicio externo (Pangolin) y el proxy interno intenta resolverlo con el DNS local, el cliente ACME no puede validar el desafío y no genera el certificado.

  2. Resolución interna que evita el proxy TLS
    Cuando el cliente interno resuelve paperless.app.my.domain a 192.168.0.252, la conexión se establece directamente con el contenedor sin pasar por Traefik. Las etiquetas de Traefik (tls: true) quedan sin efecto y el contenedor responde sin TLS, provocando que la aplicación reclame “no HTTPS”.

  3. Puertos publicados solo en la LAN
    Si el contenedor expone el puerto 8000 directamente y el DNS interno apunta a la IP del host, el tráfico bypassa el entrypoint websecure de Traefik. El certificado se sirve solo en el puerto 443 del proxy, no en el puerto interno del contenedor.

  4. Configuración de red Docker aislada
    Un macvlan para AdGuard funciona, pero si Traefik y los servicios están en una red bridge distinta, la resolución de nombres internos puede colisionar con la IP externa del mismo subdominio, generando rutas ambiguas.

Solución

La solución consiste en unificar la resolución DNS y centralizar la gestión de certificados en Traefik, de modo que tanto el tráfico interno como el externo pase siempre por el mismo reverse proxy. El flujo recomendado:

  1. Mantener la zona DNS pública en el proveedor original (Hetzner, Cloudflare, etc.) y crear los registros A/CNAME que apunten a la IP pública del router.

  2. Configurar un DNS recursivo interno que haga “split‑horizon”: para *.app.my.domain devuelve la IP local del host (192.168.0.252), pero para _acme-challenge.* delega a los servidores autoritativos públicos. En AdGuard esto se logra con una regla “Conditional Forwarding” que envía los sub‑dominios _acme-challenge al resolutor público.

  3. Habilitar el provider DNS de Traefik que apunte al mismo proveedor que gestiona la zona pública. Por ejemplo, si la zona está en Hetzner, usar providers.dnsChallenge.provider = "hetzner" y proporcionar la API‑key. De esta forma Traefik puede completar el desafío DNS‑01 sin interferir con la resolución interna.

  4. Forzar que todo el tráfico interno use el entrypoint websecure. En Docker‑compose, no exponer puertos internos de los servicios; solo exponer los puertos del proxy (80/443). Cada contenedor debe escuchar en su puerto interno (ej. 8000) y confiar en Traefik para TLS.

  5. Etiquetas de Traefik – ejemplo mínimo:

    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.paperless.rule=Host(`paperless.app.my.domain`)"
      - "traefik.http.routers.paperless.entrypoints=websecure"
      - "traefik.http.routers.paperless.tls=true"
      - "traefik.http.services.paperless.loadbalancer.server.port=8000"
    
  6. Configurar el static config de Traefik para que el entrypoint websecure use TLS con ACME y el provider DNS correcto:

    # see Código section
    
  7. Re‑generar los certificados: eliminar los certificados antiguos (/letsencrypt) y reiniciar Traefik. El primer request a https://paperless.app.my.domain disparará el desafío DNS‑01 y creará un certificado válido tanto para la IP pública como para la interna (el certificado no depende de la IP, sino del nombre).

Con este enfoque, la resolución interna sigue devolviendo la IP local, pero el desafío ACME se envía a los servidores públicos, evitando el “loop” de validación. Todas las aplicaciones reciben HTTPS directamente del proxy, sin necesidad de exponer puertos internos.

Cuándo aplicar esta solución

  • Homelabs con acceso externo y dominio propio gestionado por un DNS externo.
  • Servicios que requieren HTTPS estricto (Jellyfin, Paperless, Nextcloud, etc.).
  • Redes Docker con varios contenedores que comparten un único reverse proxy.
  • Entornos donde el DNS interno es administrado por AdGuard, Pi‑hole o similar y se necesita split‑horizon.

No es necesario si:

  • Solo se accede a los servicios desde la LAN y no se necesita certificado externo.
  • Se usa un único proveedor DNS que permite crear registros A apuntando a la IP local sin necesidad de split‑horizon.

Código

# traefik.yml (static configuration)
log:
  level: INFO

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false

certificatesResolvers:
  dns_hetzner:
    acme:
      email: "[email protected]"
      storage: "/letsencrypt/acme.json"
      dnsChallenge:
        provider: "hetzner"
        # opcional: delayBeforeCheck: 0
# docker-compose.yml (fragmento relevante)
services:
  traefik:
    image: traefik:v2.11
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "[email protected]"
      - "--certificatesresolvers.dns_hetzner.acme.storage=/letsencrypt/acme.json"
      - "--certificatesresolvers.dns_hetzner.acme.dnschallenge.provider=hetzner"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./letsencrypt:/letsencrypt"
    networks:
      - proxy

  paperless:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.paperless.rule=Host(`paperless.app.my.domain`)"
      - "traefik.http.routers.paperless.entrypoints=websecure"
      - "traefik.http.routers.paperless.tls=true"
      - "traefik.http.services.paperless.loadbalancer.server.port=8000"
    networks:
      - proxy

networks:
  proxy:
    external: true

Verificación

  1. Comprobar resolución DNS interna

    dig @127.0.0.1 paperless.app.my.domain +short
    # debe devolver 192.168.0.252
    dig @127.0.0.1 _acme-challenge.paperless.app.my.domain +short
    # debe devolver la IP pública del resolutor DNS del proveedor (p.e. 1.1.1.1)
    
  2. Validar certificado

    openssl s_client -connect paperless.app.my.domain:443 -servername paperless.app.my.domain </dev/null | openssl x509 -noout -dates -subject
    # La fecha de expiración debe ser futura y el CN debe coincidir con paperless.app.my.domain
    
  3. Acceso desde la LAN
    Abrir https://paperless.app.my.domain en un navegador dentro de la red. No debe aparecer advertencia de certificado y la página debe cargar rápidamente (sin salto a la IP pública).

  4. Acceso desde fuera
    Repetir el paso anterior desde una conexión móvil o VPN externa. El mismo certificado debe ser válido y la latencia debe ser comparable a la del acceso externo directo.

  5. Logs de Traefik
    Revisar docker logs traefik para asegurarse de que el resolver DNS‑01 se completó sin errores y que los routers están “UP”.

Notas adicionales

  • TTL bajo en los registros _acme-challenge: Hetzner permite TTL de 60 s, lo que acelera la renovación automática.
  • AdGuard split‑horizon: la regla “Conditional Forwarding” debe apuntar a los servidores DNS del proveedor (p.e. ns1.hetzner.com). De lo contrario, el desafío fallará porque AdGuard intentará resolver _acme-challenge internamente.
  • No exponer puertos internos: si algún contenedor necesita acceso directo (por ejemplo, una base de datos), colócalo en una red separada y usa network_mode: "service:traefik" solo para los servicios HTTP.
  • Renovación: Traefik renueva automáticamente los certificados 30 días antes de su expiración. Mantén el volumen /letsencrypt persistente para evitar perder el estado.
  • Fallback HTTP → HTTPS: añade una redirección en el router web para forzar HTTPS y evitar que alguna aplicación caiga en HTTP por error de configuración.

Con esta arquitectura, el mismo nombre DNS funciona sin problemas tanto dentro como fuera de la red, los certificados se gestionan de forma automática y las aplicaciones reciben siempre una conexión segura.