Volver a publicaciones

Redes y seguridad

Caddy como puerta de entrada del homelab: HTTPS, cabeceras seguras y CrowdSec

11 min de lectura

#caddy#https#docker#crowdsec#seguridad#cloudflare

Portada: candado con flechas de entrada sobre fondo oscuro con el título Caddy como puerta de entrada

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 como localhost:PUERTO y no por nombre de contenedor. Si pones reverse_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:

  1. 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.
  2. 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 appsec hizo 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.

Everything I publish from home goes through the same door: Caddy. Each service has its own subdomain, all of them use HTTPS and none is exposed directly. If I switch Caddy off tomorrow, nothing is visible from outside.

At first I only used it to get certificates. Then I looked at the logs and saw that a single IP had made more than 10,000 requests. All of them got a 403, but each one cost CPU: negotiating TLS and evaluating the rules. That is when I understood the proxy does not just route traffic, it is also the first line of defence. This guide is the setup I use today.

What you will build

  • Caddy in Docker, compiled with the Cloudflare module to request certificates through DNS.
  • A Caddyfile with reusable snippets: security headers, sensitive paths, logs and LAN/VPN-only access.
  • The split between public services and internal panels.
  • CrowdSec reading Caddy’s logs to ban abusive IPs.

Requirements

  • Docker and Docker Compose on the NAS.
  • A domain managed in Cloudflare (in the examples, example.com).
  • A Cloudflare API token with Zone → DNS → Edit permission on that zone.
  • Access to the router to forward a port.

In the examples, 192.168.1.10 is your NAS IP, 192.168.1.0/24 your home network and 10.8.0.0/24 your VPN. They are example values: replace them with yours.

Step 1: a Caddy image with the Cloudflare module

The official Caddy image ships no DNS providers. You have to build it with xcaddy, the official tool for adding modules. I do it in a two-stage Dockerfile: the first stage compiles and the second only copies the binary.

# Stage 1: build Caddy with the modules I need
FROM caddy:2-builder AS builder
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/hslatman/caddy-crowdsec-bouncer/http

# Stage 2: final image, same as the official one but with our binary
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

The second module is the CrowdSec bouncer, used in step 6. If you will not use it, remove that line.

Step 2: the hardened docker-compose.yml

services:
  caddy:
    build: .                          # uses the Dockerfile in this folder
    image: caddy-cloudflare:local     # name of the built image
    container_name: caddy
    network_mode: host                # listens directly on the NAS network
    env_file: .env                    # Cloudflare token and CrowdSec key
    security_opt:
      - no-new-privileges:true        # no process can gain privileges
    cap_drop:
      - ALL                           # drop every Linux capability...
    cap_add:
      - NET_BIND_SERVICE              # ...except the one to open ports
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro   # configuration, read-only
      - ./data:/data                  # certificates: include it in your backups
      - ./config:/config              # Caddy's autosaved configuration
      - ./logs:/var/log/caddy         # per-service logs (read by 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

Three important decisions:

  • network_mode: host. Caddy is not on any Docker network: it sees the NAS ports like any other program. That is why, in the Caddyfile, services are targeted as localhost:PORT and not by container name. If you write reverse_proxy jellyfin:8096, you will get a 502.
  • The data folder. That is where the certificates live. If you lose it, Caddy requests all of them again at once and you can hit Let’s Encrypt’s rate limits. Put it in your backups.
  • The healthcheck queries Caddy’s admin API, which only listens on 127.0.0.1:2019. Nobody from outside can reach it.

And the .env file, in the same folder:

# .env: never commit it to a repository
CLOUDFLARE_API_TOKEN=
CROWDSEC_API_KEY=

Fill it in and protect it so only your user can read it:

chmod 600 .env

Step 3: ports on a Synology NAS

On Synology, the system (DSM) already uses ports 80 and 443. Instead of fighting it, Caddy listens on 8080 and 8443, and on the router I create a forward: external 443 → NAS 8443.

I do not open port 80. With the Cloudflare DNS challenge it is not needed: Caddy proves it owns the domain by creating a temporary TXT record, and Let’s Encrypt checks it without connecting to my home.

Step 4: the Caddyfile piece by piece

Global options

{
    admin 127.0.0.1:2019                          # admin API, local only
    http_port 8080                                # DSM uses 80
    https_port 8443                               # DSM uses 443
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}  # certificates via DNS challenge
}

Reusable snippets

A snippet is a named block in parentheses that you insert later with import. That way I write each rule only once.

Security headers. I hide which software I run, stop other sites from putting me in an iframe and force 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
}

LAN/VPN only. Anything not coming from home, the VPN or the NAS itself gets a 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
}

Sensitive paths. Automated scanners always look for the same things: .env files, .git repositories, database dumps, WordPress panels… I answer them with a 404 without bothering the service:

(sensitive_paths) {
    @sensitive path *.env* *.git* *.sql /wp-admin* /wp-login.php /phpmyadmin* /actuator* /.aws*
    respond @sensitive 404
}

Per-service logs. One JSON file per service, rotated at 20 MB, keeping 5 copies. {args[0]} is the name I pass when importing:

(log_service) {
    log {
        output file /var/log/caddy/{args[0]}.log {
            roll_size 20mb
            roll_keep 5
        }
        format json
    }
}

Step 5: public services and internal panels

My rule is simple: what other people use is public; any admin panel is internal. A public service:

app.example.com {
    import security_headers
    import sensitive_paths
    import log_service app
    reverse_proxy localhost:3000
}

An internal panel also gets internal_only. From outside it returns a 403 even if someone guesses the subdomain:

adguard.example.com {
    import internal_only
    import security_headers
    import log_service adguard
    reverse_proxy localhost:3100
}

Step 6: CrowdSec against heavy IPs

Back to the IP with 10,000 requests. The 403 slowed it down but did not kick it out. CrowdSec solves that in two parts:

  1. The agent reads Caddy’s logs, detects attack patterns (scans, brute force, sensitive paths) and decides on a ban. In my case, an IP gets banned after about 10 suspicious attempts.
  2. The bouncer enforces the decision. It is compiled into Caddy (the module from step 1), so a banned IP is cut off before it reaches any rule.

For the agent to read the logs, install the Caddy collection (cscli collections install crowdsecurity/caddy) and add this data source (acquis.yaml), with the logs folder mounted in the CrowdSec container:

filenames:
  - /var/log/caddy/*.log
labels:
  type: caddy

CrowdSec’s local API (LAPI) listens on 8080 by default, which Caddy already uses. So I move it to 127.0.0.1:8082 with the listen_uri option in its configuration. Then I create a key for the bouncer and store it in .env as CROWDSEC_API_KEY:

docker exec crowdsec cscli bouncers add caddy-bouncer

In Caddy’s global options I add:

{
    order crowdsec first                  # the bouncer acts before anything else
    crowdsec {
        api_url http://127.0.0.1:8082
        api_key {$CROWDSEC_API_KEY}
    }
}

And in each site I want to protect, one line: crowdsec.

Optional: the CrowdSec WAF (AppSec)

CrowdSec also has an AppSec component that inspects every request like a WAF. It works, but with two warnings:

  • Without the “fail open” option, if the AppSec component restarts, the sites using it return 500 in the meantime.
  • In my version, a block with appsec made the Caddyfile adapter silently drop the following block. After any change, I check that all my domains are still in the real configuration:
docker exec caddy caddy adapt --config /etc/caddy/Caddyfile | grep -c example.com

If the number drops compared to last time, something got lost.

Lesson 1: the Caddyfile that would not update

I edited the Caddyfile from Windows over SMB, ran caddy reload and… nothing changed. Not a single error.

The cause: Docker mounts a single file by its inode, not by its name. Many editors, SMB copies and the mv command do not modify the file: they create a new one and put it in its place. The container keeps seeing the old one, so caddy reload reloads the same configuration as before.

The fix is to restart the container after every change. And before that, validate with a throwaway container, so a syntax error does not take the proxy down.

Lesson 2: the gateway that was an open proxy

Some of my containers have no Internet access but need to talk to an external API. For that I use Caddy as an egress gateway: the container calls Caddy and Caddy calls the API.

The problem: if you do not restrict who can use it, anyone who reaches that port gets a free proxy. Always limit it by source IP:

:9000 {
    @blocked not remote_ip 10.20.0.0/24     # Docker network of those containers (example)
    respond @blocked 403
    reverse_proxy https://api.example.org {
        header_up Host {upstream_hostport}
    }
}

My change routine: back up, validate, restart

# 1. Timestamped backup
cp Caddyfile Caddyfile.bak.$(date +%Y%m%d_%H%M%S)

# 2. Edit the Caddyfile and validate it with a throwaway container
docker run --rm --env-file .env \
  -v "$(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro" \
  caddy-cloudflare:local caddy validate --config /etc/caddy/Caddyfile

# 3. If it says "Valid configuration", restart and check the logs
docker restart caddy
docker logs --tail 50 caddy

# 4. If something breaks, go back to the copy (use your own timestamp)
cp Caddyfile.bak.20260927_101500 Caddyfile
docker restart caddy

The complete Caddyfile

{
    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
    }
}

# Public service
app.example.com {
    crowdsec
    import security_headers
    import sensitive_paths
    import log_service app
    reverse_proxy localhost:3000
}

# Internal panel: LAN and VPN only
adguard.example.com {
    crowdsec
    import internal_only
    import security_headers
    import log_service adguard
    reverse_proxy localhost:3100
}

Common problems

Symptom Likely cause Fix
502 Bad Gateway You target a container name with network_mode: host Use localhost:PORT
Certificates are not issued Token without DNS Edit permission or empty variable Check the token and docker exec caddy env
caddy reload does not apply changes Caddyfile replaced (mv, editor, SMB) docker restart caddy
A domain stops responding after a change The adapter dropped a block caddy adapt ... | grep -c example.com
Every site with AppSec returns 500 The AppSec component restarted Check CrowdSec or remove appsec from those sites
Caddy will not start: port in use DSM or another service holds the port Use http_port/https_port and forward on the router

Conclusion

With Caddy as the only door, publishing a new service takes five lines and three imports. What really gave me peace of mind are the details: internal panels closed by default, per-service logs and CrowdSec cutting off whoever insists. And one habit: back up, validate and restart before calling anything done.

In the next chapter I set up AdGuard Home to block ads across the whole house and, along the way, resolve my subdomains without leaving the network.

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.

Compartir:LinkedInX
ivanjonasfc@dev: ~ — zshsesión en vivo

❯ neofetch

Perfilivanjonasfc@dev

Iván Jonás Fernández Correa
user
ivanjonasfc@dev
role
Full-Stack & DevOps
loc
Gijón, Asturias
títulos
DAM + ASIR
stack
Java · Python · TS · Docker · Linux · IA
busco
Full-Stack · DevOps · Sistemas
status
Disponible para trabajar

❯ uptime --human

Años en tecnología
6+
Proyectos en producción
6
Proyectos entregados
10+
Servicios self-hosted
~30

❯ figlet "Iván Jonás"

# Hola, soyIván Jonás

Desarrollador Full-Stack & DevOpsFull-Stack Developer & DevOps

Desarrollador Full-Stack & DevOps · Gijón, Asturias · ● Disponible para trabajar

↓ desliza, toca o escribe para abrir la sesión

❯ cat about.md

Desarrollo aplicaciones web y móviles de principio a fin (Java/Spring Boot, Python/FastAPI, TypeScript/React y Flutter) y me ocupo de su puesta en producción y operación con Docker, Linux, CI/CD y monitorización. Actualmente administro la infraestructura IT de una pyme y mantengo un homelab de unos 30 servicios en el que evalúo nuevas tecnologías, incluida IA local y privada (Ollama, RAG, MCP). Técnico Superior en DAM y ASIR.

❯ ls ~

Iván en números

Mi actividad en GitHub, mi trayectoria y mi infraestructura · datos en vivo.

Community

0

followers

followerfollowerfollowerfollowerfollower+8

Lenguajes

TypeScript32%
Dart14%
Python12%
HTML7%
SCSS5%

Tech Stack

Frontend

React
Next.js
Angular
Astro
Ionic
Flutter
TypeScript
Tailwind
shadcn/ui
React
Next.js
Angular
Astro
Ionic
Flutter
TypeScript
Tailwind
shadcn/ui

Backend

NestJS
FastAPI
Spring Boot
Python
Java
PostgreSQL
Redis
Qdrant
pgvector
MCP
RAG
LLMs
NestJS
FastAPI
Spring Boot
Python
Java
PostgreSQL
Redis
Qdrant
pgvector
MCP
RAG
LLMs

Tools

Docker
Linux
Nginx
Cloudflare
Forgejo
n8n
WireGuard
Prometheus
Grafana
Git
Windows Server
Active Directory
Loki
Tempo
Docker
Linux
Nginx
Cloudflare
Forgejo
n8n
WireGuard
Prometheus
Grafana
Git
Windows Server
Active Directory
Loki
Tempo

Proyectos Destacados

Soluciones reales a problemas reales. Una selección de mis trabajos más recientes.

Web Rutas Raíces
Cliente real

Web Rutas Raíces

Web de senderismo para cliente real en Asturias. Astro 7 + React 19 + Tailwind 4, mapas Leaflet con perfil de elevación a partir de GPX y versión bilingüe ES/EN. Alta de rutas automatizada con n8n + IA; WebP, CSP y cumplimiento RGPD.

AstroReactLeafletn8n
Infraestructura IT para una pyme
Cliente real

Infraestructura IT para una pyme

Diseño, implantación y soporte continuo de la infraestructura IT de una pequeña empresa que trabajaba desde un único portátil: NAS en RAID 1 con Btrfs y snapshots, permisos por grupos sobre SMB, puestos Windows 11 y una capa Docker para gestión documental, automatización y monitorización.

NASRAID 1BtrfsWindows 11DockerPaperless-ngx
OposApp
TFG

OposApp

Mi Trabajo de Fin de Grado (DAM): app móvil en Flutter que automatiza el seguimiento de convocatorias del BOPA y genera tests de práctica con IA local (Ollama), con privacidad total por diseño.

FlutterSpring BootIA Local (Ollama)PostgreSQLRGPD
English Brain — Tu coach de entrevistas en inglés con IA, autoalojado
Open Source · Activo

English Brain — Tu coach de entrevistas en inglés con IA, autoalojado

Una plataforma privada y autoalojada para preparar entrevistas técnicas en inglés hablando, no escribiendo. Tocas un orbe, hablas, y el coach te transcribe, te corrige la pronunciación fonema a fonema y te programa el repaso con repetición espaciada (py-fsrs). Toda la IA —el modelo de lenguaje, el reconocimiento de voz y la síntesis— corre en tu propio hardware: tu voz y tus respuestas nunca salen de tu red. Por dentro: cliente Flutter (Android, Web y Windows), orquestador FastAPI y una arquitectura GPU híbrida sobre Docker + Caddy.

IA localVozFlutterFastAPISelf-hostedPronunciación
QuantumSec — Kompliance
FCT 2025

QuantumSec — Kompliance

Prácticas FCT en remoto en una empresa de ciberseguridad (plataforma de cumplimiento Kompliance). Llevé su gestor de auditorías de Supabase a una API propia self-hosted con FastAPI: multitenancy validado por JWT, secretos cifrados, logs de auditoría inmutables y un gestor de contraseñas con claves de un solo uso cifradas.

FastAPIReactTypeScriptPostgreSQLSeguridad
DozeForge — Control total de tu Android por ADB (sin root)
Open Source · Beta 1

DozeForge — Control total de tu Android por ADB (sin root)

App de escritorio que te da el control real de tu Android desde el PC, sin rootear. La conectas por ADB y optimizas batería, memoria, red y decenas de ajustes del sistema desde un solo sitio: caza las apps que te drenan la batería (aunque se escondan tras Google Play Services), quita bloatware de fábrica y aplica perfiles de un clic — y todo es reversible con rollback atómico. Por dentro: Tauri 2 + Rust + SvelteKit 5, telemetría real (dumpsys/sysfs) y primitivas públicas de Android.

Android sin rootOptimizaciónTauri 2RustSvelteKit
SubsForge — Subtítulos con IA, 100% en local
Open Source

SubsForge — Subtítulos con IA, 100% en local

App de escritorio para traducir, generar, sincronizar, fusionar, limpiar, doblar, resumir e incrustar subtítulos de vídeo con IA — sin servicios de terceros y funcionando 100% en tu máquina. Nueve herramientas en una: traducción por lotes (nube u Ollama local), transcripción con Whisper, sincronización por detección de voz (VAD), doblaje con voces TTS, hardsub y limpieza de audio. Por dentro: Tauri 2 + Rust como puente y un sidecar de Python (faster-whisper, FFmpeg, ffsubsync, edge-tts) que hace el trabajo pesado sin bloquear nunca la interfaz.

Subtítulos IAWhisperTauri 2RustPython100% local
OrquestaGit — Tu DevOps senior local con IA
Open Source · En desarrollo

OrquestaGit — Tu DevOps senior local con IA

Una app de escritorio que automatiza el trabajo repetitivo de DevOps sobre tus repositorios: escanea su estado, bloquea secretos antes de cada commit, genera pipelines de CI/CD y audita dependencias, todo desde un único panel de control. La IA corre en local con Ollama, así que tu código nunca sale de tu equipo. Construida con Tauri 2 y un motor de orquestación en Python.

DevOpsIA localSeguridadTauri 2PythonCI/CD
Job-radar · Empleo CRM
Open Source

Job-radar · Empleo CRM

CRM web self-hosted que automatiza y centraliza mi búsqueda de empleo: reúne las ofertas, las puntúa con IA (Groq/Gemini/Ollama), genera carta y ajustes de CV, y las sigue en un kanban. Astro SSR + React + PostgreSQL, todo configurable desde la web y replicable con un comando.

Astro SSRReactPostgreSQLIA · LLMsn8nDocker
LifeOS — Suite familiar con IA
En desarrollo

LifeOS — Suite familiar con IA

Suite SaaS familiar autohospedada con IA propia ("Lito"). Backend NestJS + PostgreSQL + BullMQ y app Angular 18 + Ionic. RAG sobre pgvector/Qdrant y LLMs vía Model Context Protocol (MCP). Módulos de chat, gastos con splits en tiempo real, calendario, nube y notas.

NestJSAngularIA / RAGMCPDocker
Infraestructura Pesoz
En Producción

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.

Dockerk3sCaddyWireGuardCI/CDObservabilidad
Hooklab
Nuevo

Hooklab

Constructor, firmador y verificador de webhooks 100% en el navegador: crea payloads con variables simuladas, firma y verifica HMAC (GitHub, Stripe, Shopify, Slack), genera con IA local y dispara un POST. Zero-backend, con el estado compartible en la URL.

AstroZero-backendWeb CryptoIA local
Acopio de Código
Nuevo

Acopio de Código

Página web interactiva con snippets de código de varios lenguajes: busca, copia y ten todo a mano. Autohospedada en mi propia infraestructura.

ReactSnippetsSelf-hosted
Vagabond Battery Saver
Open Source

Vagabond Battery Saver

Módulo Magisk para Android root. Optimización agresiva de batería mediante gestión de governors de CPU y perfiles de energía al bloquear la pantalla.

AndroidShell ScriptMagisk
Happines&CO
Educativo

Happines&CO

Sitio web corporativo para empresa de eventos, desarrollado durante el ciclo de DAM. Diseño responsive y estructura semántica limpia.

HTML5CSS3JavaScript
Portfolio Web
Meta

Portfolio Web

Este mismo sitio. Astro 7 + React (islas) + Tailwind, self-hosted con Docker y Caddy. Cada proyecto es una carpeta con su contenido y se da de alta con un script (npm run proyecto:add).

AstroReactTailwindSelf-hosted

Últimas publicaciones

Guías prácticas probadas en mi propia infraestructura.

Portada: escudo con símbolo de prohibido sobre fondo oscuro con el título AdGuard Home
Redes y seguridad

AdGuard Home en Docker: sin publicidad en toda la casa y tus subdominios en local

Monto AdGuard Home en Docker para bloquear publicidad en toda la red, usar DNS cifrado y resolver mis subdominios en casa sin depender del router.

8 min de lectura

#adguard-home#dns#docker#privacidad#homelab
Leer
Portada: candado con flechas de entrada sobre fondo oscuro con el título Caddy como puerta de entrada
Redes y seguridad

Caddy como puerta de entrada del homelab: HTTPS, cabeceras seguras y CrowdSec

Cómo uso Caddy en Docker como única entrada a mi NAS: HTTPS por DNS de Cloudflare, snippets de seguridad, paneles solo LAN y CrowdSec contra IPs abusivas.

11 min de lectura

#caddy#https#docker#crowdsec#seguridad#cloudflare
Leer
Portada: una caja de contenedor con una campana de aviso y un avión de papel sobre fondo oscuro con el título Diun y Telegram
Automatización

Diun y alertas en Telegram: entérate de las imágenes nuevas sin actualizar a ciegas

Uso Diun para saber cuándo hay imágenes Docker nuevas y un único bot de Telegram para recibir los avisos de Diun, CrowdSec y Uptime Kuma.

7 min de lectura

#diun#docker#telegram#alertas#observabilidad
Leer
Portada: ramas de Git que se unen en una marca de verificación sobre fondo oscuro con el título Forgejo y GitHub Actions
Automatización

Forgejo y GitHub Actions: tu infraestructura en Git y un repo público profesional

Monto Forgejo en casa como fuente de verdad de mi infraestructura, protejo los secretos y preparo un repo público con CI, Dependabot y plantillas.

13 min de lectura

#git#forgejo#github-actions#ci#gitops#buenas-practicas
Leer
Portada: logotipo de reproducción sobre fondo oscuro con el título Jellyfin en mi NAS
Homelab

Jellyfin en un NAS: Docker, transcoding por hardware y la TV que no quería conectar

Cómo monto Jellyfin en un NAS con Intel, activo el transcoding por GPU, lo publico con Caddy y arreglo el error -27 de las TV LG antiguas.

7 min de lectura

#jellyfin#docker#nas#caddy#transcoding#synology
Leer
Portada: gráfica de líneas y barras de un panel de métricas sobre fondo oscuro con el título Observabilidad en casa
Homelab

Observabilidad en casa: Grafana, Prometheus y Loki sin ahogar el NAS

Monto métricas, logs y alertas con Prometheus, Grafana y Loki, y cuento por qué acabé moviendo el stack del NAS a un portátil con k3s.

11 min de lectura

#grafana#prometheus#loki#observabilidad#docker#kubernetes
Leer
Portada: línea de latido y barras de disponibilidad sobre fondo oscuro con el título Uptime Kuma
Homelab

Uptime Kuma: vigila tus servicios y enseña su estado en vivo en tu CV

Monto Uptime Kuma en Docker, configuro monitores y avisos útiles, publico solo la página de estado y la muestro en mi portfolio con un widget en JS.

8 min de lectura

#uptime-kuma#monitorizacion#docker#caddy#portfolio
Leer

Trabajemos juntos

¿Buscas un perfil full-stack / DevOps, un colaborador o un freelance? Cuéntame qué necesitas y te respondo en menos de 24 h.

ivanjonasfc@dev:~/contacto

# ¿Qué te trae por aquí?

↳
↳
↳

Sin spam. Tus datos solo se usan para responderte.

Terminal interactiva