Redes y seguridad
Caddy como puerta de entrada del homelab: HTTPS, cabeceras seguras y CrowdSec
11 min de lectura
Puntos clave
- Un solo proxy para todo: un subdominio por servicio y HTTPS automático con Caddy.
- Certificados por reto DNS de Cloudflare: no hace falta abrir el puerto 80.
- Snippets reutilizables para cabeceras, rutas sensibles, logs y paneles solo LAN/VPN.
- CrowdSec lee los logs de Caddy y banea las IPs que insisten, antes de que gasten CPU.
- Un Caddyfile montado como fichero no ve los cambios hechos con mv o por SMB: toca reiniciar.
Todo lo que publico desde casa pasa por la misma puerta: Caddy. Cada servicio tiene su subdominio, todos llevan HTTPS y ninguno se expone directamente. Si mañana apago Caddy, desde fuera no se ve nada.
Al principio lo usaba solo para tener certificados. Luego miré los logs y vi que una sola IP había hecho más de 10.000 peticiones. Todas recibieron un 403, pero cada una costaba CPU: negociar TLS y evaluar las reglas. Ahí entendí que el proxy no solo reparte tráfico, también es la primera línea de defensa. Esta guía es la configuración que uso hoy.
Qué vas a montar
- Caddy en Docker, compilado con el módulo de Cloudflare para pedir certificados por DNS.
- Un Caddyfile con snippets reutilizables: cabeceras de seguridad, rutas sensibles, logs y acceso solo LAN/VPN.
- La separación entre servicios públicos y paneles internos.
- CrowdSec leyendo los logs de Caddy para banear IPs abusivas.
Requisitos
- Docker y Docker Compose en el NAS.
- Un dominio gestionado en Cloudflare (en los ejemplos,
example.com). - Un token de la API de Cloudflare con permiso Zone → DNS → Edit sobre esa zona.
- Acceso al router para redirigir un puerto.
En los ejemplos, 192.168.1.10 es la IP de tu NAS, 192.168.1.0/24 tu red de casa y 10.8.0.0/24 tu VPN. Son valores de ejemplo: cámbialos por los tuyos.
Paso 1: una imagen de Caddy con el módulo de Cloudflare
La imagen oficial de Caddy no trae proveedores DNS. Hay que compilarla con xcaddy, la herramienta oficial para añadir módulos. Lo hago en un Dockerfile de dos etapas: la primera compila y la segunda solo copia el binario.
# Etapa 1: compilar Caddy con los módulos que necesito
FROM caddy:2-builder AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/hslatman/caddy-crowdsec-bouncer/http
# Etapa 2: imagen final, igual que la oficial pero con nuestro binario
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
El segundo módulo es el bouncer de CrowdSec, que usaremos en el paso 6. Si no lo vas a usar, quita esa línea.
Paso 2: el docker-compose.yml endurecido
services:
caddy:
build: . # usa el Dockerfile de esta carpeta
image: caddy-cloudflare:local # nombre de la imagen construida
container_name: caddy
network_mode: host # escucha directamente en la red del NAS
env_file: .env # token de Cloudflare y clave de CrowdSec
security_opt:
- no-new-privileges:true # ningún proceso puede ganar privilegios
cap_drop:
- ALL # quita todas las capacidades de Linux...
cap_add:
- NET_BIND_SERVICE # ...menos la de abrir puertos
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro # configuración, solo lectura
- ./data:/data # certificados: inclúyelo en tus copias
- ./config:/config # configuración autosalvada de Caddy
- ./logs:/var/log/caddy # logs por servicio (los lee CrowdSec)
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
Tres decisiones importantes:
network_mode: host. Caddy no está en ninguna red Docker: ve los puertos del NAS como si fuera un programa más. Por eso, en el Caddyfile, los servicios se apuntan comolocalhost:PUERTOy no por nombre de contenedor. Si ponesreverse_proxy jellyfin:8096, obtendrás un 502.- La carpeta
data. Ahí están los certificados. Si la pierdes, Caddy los vuelve a pedir todos a la vez y puedes chocar con los límites de Let’s Encrypt. Métela en tus copias de seguridad. - El healthcheck consulta la API de administración de Caddy, que solo escucha en
127.0.0.1:2019. Nadie de fuera puede llegar a ella.
Y el archivo .env, en la misma carpeta:
# .env: nunca lo subas a un repositorio
CLOUDFLARE_API_TOKEN=
CROWDSEC_API_KEY=
Rellénalo y protégelo para que solo tu usuario pueda leerlo:
chmod 600 .env
Paso 3: puertos en un NAS Synology
En Synology, el sistema (DSM) ya usa los puertos 80 y 443. En lugar de pelearme con él, Caddy escucha en 8080 y 8443, y en el router creo una redirección: 443 externo → 8443 del NAS.
El puerto 80 no lo abro. Con el reto DNS de Cloudflare no hace falta: Caddy demuestra que el dominio es suyo creando un registro TXT temporal, y Let’s Encrypt lo comprueba sin conectarse a mi casa.
Paso 4: el Caddyfile por piezas
Opciones globales
{
admin 127.0.0.1:2019 # API de administración solo local
http_port 8080 # DSM ocupa el 80
https_port 8443 # DSM ocupa el 443
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} # certificados por reto DNS
}
Snippets reutilizables
Un snippet es un bloque con nombre entre paréntesis que luego insertas con import. Así escribo cada regla una sola vez.
Cabeceras de seguridad. Oculto qué software uso, impido que otras webs me metan en un iframe y obligo a HTTPS:
(security_headers) {
header {
-Server
-X-Powered-By
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
encode zstd gzip
}
Solo LAN/VPN. Todo lo que no venga de casa, de la VPN o del propio NAS recibe un 403:
(internal_only) {
@external not remote_ip 192.168.1.0/24 10.8.0.0/24 127.0.0.1/8
respond @external 403
}
Rutas sensibles. Los escáneres automáticos buscan siempre lo mismo: archivos .env, repositorios .git, copias de bases de datos, paneles de WordPress… Les respondo 404 sin molestar al servicio:
(sensitive_paths) {
@sensitive path *.env* *.git* *.sql /wp-admin* /wp-login.php /phpmyadmin* /actuator* /.aws*
respond @sensitive 404
}
Logs por servicio. Un archivo JSON por servicio, que rota a los 20 MB y guarda 5 copias. {args[0]} es el nombre que paso al importar:
(log_service) {
log {
output file /var/log/caddy/{args[0]}.log {
roll_size 20mb
roll_keep 5
}
format json
}
}
Paso 5: servicios públicos y paneles internos
Mi regla es sencilla: lo que usan otras personas es público; cualquier panel de administración es interno. Un servicio público:
app.example.com {
import security_headers
import sensitive_paths
import log_service app
reverse_proxy localhost:3000
}
Un panel interno lleva, además, internal_only. Desde fuera recibe un 403 aunque adivinen el subdominio:
adguard.example.com {
import internal_only
import security_headers
import log_service adguard
reverse_proxy localhost:3100
}
Paso 6: CrowdSec contra las IPs pesadas
Volvamos a la IP de las 10.000 peticiones. El 403 la frenaba, pero no la echaba. CrowdSec resuelve eso en dos partes:
- El agente lee los logs de Caddy, detecta patrones de ataque (escaneos, fuerza bruta, rutas sensibles) y decide un baneo. En mi caso, una IP queda baneada tras unos 10 intentos sospechosos.
- El bouncer aplica la decisión. Va compilado dentro de Caddy (el módulo del paso 1), así que una IP baneada se corta antes de llegar a ninguna regla.
Para que el agente lea los logs, instala la colección de Caddy (cscli collections install crowdsecurity/caddy) y añade esta fuente de datos (acquis.yaml), con la carpeta logs montada en el contenedor de CrowdSec:
filenames:
- /var/log/caddy/*.log
labels:
type: caddy
La API local de CrowdSec (LAPI) escucha por defecto en el 8080, que ya usa Caddy. Por eso la muevo a 127.0.0.1:8082 con la opción listen_uri de su configuración. Luego creo una clave para el bouncer y la guardo en .env como CROWDSEC_API_KEY:
docker exec crowdsec cscli bouncers add caddy-bouncer
En las opciones globales de Caddy añado:
{
order crowdsec first # el bouncer actúa antes que nada
crowdsec {
api_url http://127.0.0.1:8082
api_key {$CROWDSEC_API_KEY}
}
}
Y en cada sitio que quiero proteger, una línea: crowdsec.
Opcional: el WAF de CrowdSec (AppSec)
CrowdSec también tiene un componente AppSec que inspecciona cada petición como un WAF. Funciona, pero con dos avisos:
- Sin la opción de “fallo abierto”, si el componente AppSec se reinicia, los sitios que lo usan devuelven 500 mientras tanto.
- En mi versión, un bloque con
appsechizo que el adaptador del Caddyfile descartara en silencio el bloque siguiente. Después de cualquier cambio, compruebo que todos mis dominios siguen en la configuración real:
docker exec caddy caddy adapt --config /etc/caddy/Caddyfile | grep -c example.com
Si el número baja respecto a la vez anterior, algo se ha perdido.
Lección 1: el Caddyfile que no se actualizaba
Editaba el Caddyfile desde Windows por SMB, lanzaba caddy reload y… nada cambiaba. Ni un error.
La causa: Docker monta un fichero suelto por su inodo, no por su nombre. Muchos editores, las copias por SMB y el comando mv no modifican el archivo: crean uno nuevo y lo ponen en su lugar. El contenedor sigue viendo el viejo, así que caddy reload recarga la misma configuración de antes.
La solución es reiniciar el contenedor después de cada cambio. Y antes, validar con un contenedor desechable, para no tumbar el proxy con un error de sintaxis.
Lección 2: la pasarela que era un proxy abierto
Algunos contenedores míos no tienen salida a Internet, pero necesitan hablar con una API externa. Para eso uso Caddy como pasarela de salida: el contenedor llama a Caddy y Caddy llama a la API.
El problema: si no restringes quién puede usarla, cualquiera que llegue a ese puerto tiene un proxy gratis. Limítala siempre por IP de origen:
:9000 {
@blocked not remote_ip 10.20.0.0/24 # red Docker de los contenedores (ejemplo)
respond @blocked 403
reverse_proxy https://api.example.org {
header_up Host {upstream_hostport}
}
}
Mi rutina de cambios: copia, valida, reinicia
# 1. Copia de seguridad con fecha
cp Caddyfile Caddyfile.bak.$(date +%Y%m%d_%H%M%S)
# 2. Edita el Caddyfile y valídalo con un contenedor desechable
docker run --rm --env-file .env \
-v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro" \
caddy-cloudflare:local caddy validate --config /etc/caddy/Caddyfile
# 3. Si dice "Valid configuration", reinicia y mira los logs
docker restart caddy
docker logs --tail 50 caddy
# 4. Si algo falla, vuelve a la copia (cambia la fecha por la tuya)
cp Caddyfile.bak.20260927_101500 Caddyfile
docker restart caddy
El Caddyfile completo
{
admin 127.0.0.1:2019
http_port 8080
https_port 8443
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
order crowdsec first
crowdsec {
api_url http://127.0.0.1:8082
api_key {$CROWDSEC_API_KEY}
}
}
(security_headers) {
header {
-Server
-X-Powered-By
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
encode zstd gzip
}
(internal_only) {
@external not remote_ip 192.168.1.0/24 10.8.0.0/24 127.0.0.1/8
respond @external 403
}
(sensitive_paths) {
@sensitive path *.env* *.git* *.sql /wp-admin* /wp-login.php /phpmyadmin* /actuator* /.aws*
respond @sensitive 404
}
(log_service) {
log {
output file /var/log/caddy/{args[0]}.log {
roll_size 20mb
roll_keep 5
}
format json
}
}
# Servicio público
app.example.com {
crowdsec
import security_headers
import sensitive_paths
import log_service app
reverse_proxy localhost:3000
}
# Panel interno: solo LAN y VPN
adguard.example.com {
crowdsec
import internal_only
import security_headers
import log_service adguard
reverse_proxy localhost:3100
}
Problemas frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
| 502 Bad Gateway | Apuntas a un nombre de contenedor con network_mode: host |
Usa localhost:PUERTO |
| No consigue certificados | Token sin permiso DNS Edit o variable vacía | Revisa el token y docker exec caddy env |
caddy reload no aplica cambios |
Caddyfile reemplazado (mv, editor, SMB) | docker restart caddy |
| Un dominio deja de responder tras un cambio | El adaptador descartó un bloque | caddy adapt ... | grep -c example.com |
| Todos los sitios con AppSec dan 500 | El componente AppSec se ha reiniciado | Revisa CrowdSec o quita appsec de esos sitios |
| Caddy no arranca: puerto en uso | DSM u otro servicio ocupa el puerto | Usa http_port/https_port y redirige en el router |
Conclusión
Con Caddy como única puerta, publicar un servicio nuevo son cinco líneas y tres import. Lo que de verdad me ha dado tranquilidad son los detalles: paneles internos cerrados por defecto, logs por servicio y CrowdSec cortando a quien insiste. Y una costumbre: copia, valida y reinicia antes de dar nada por bueno.
En el próximo capítulo monto AdGuard Home para bloquear publicidad en toda la casa y, de paso, resolver mis subdominios sin salir a Internet.
Preguntas frecuentes
¿Por qué Caddy y no Nginx o Traefik?
Porque el Caddyfile es corto y legible, y los certificados HTTPS se piden y renuevan solos. Para un homelab con muchos subdominios es lo que menos mantenimiento me da.
¿Necesito abrir el puerto 80 para Let's Encrypt?
No, si usas el reto DNS. Caddy crea un registro TXT temporal en Cloudflare con tu token y Let's Encrypt lo comprueba sin conectarse a tu casa.
¿He editado el Caddyfile y el reload no hace nada, por qué?
Si el Caddyfile está montado como un único fichero y lo reemplazaste (con mv, un editor o por SMB), el contenedor sigue viendo la versión vieja. Reinicia el contenedor con docker restart.
Fuentes
Proyecto relacionado
Infraestructura Pesoz
Homelab self-hosted en producción 24/7: un NAS Synology con ~30 servicios en Docker tras Caddy + CrowdSec y un portátil con GPU como servidor de IA y observabilidad en k3s. VPN WireGuard, GitOps con Forgejo y estado en vivo con Uptime Kuma.




