Cómo construí un hub minimalista en Hetzner para Docker, Traefik y Nanobot

Cómo construí un hub minimalista en Hetzner para Docker, Traefik y Nanobot

Quería una infraestructura que pudiera explicar en cinco minutos y operar medio dormido.

Sin magia de plataformas gestionadas. Sin plano de control oculto. Solo:

Este es exactamente el playbook que uso ahora.

Qué está ejecutándose

  • Host: Hetzner Cloud (CAX11, Ubuntu 24.04)
  • Edge: Cloudflare (DNS en modo proxied)
  • Reverse proxy: Traefik (solo :80 y :443)
  • Public app: <PUBLIC_APP_DOMAIN>
  • App de Nanobot: <NANOBOT_DOMAIN>
  • Search sidecar: ddg-mcp
  • Base de seguridad: endurecimiento de SSH + fail2ban + verificaciones CVE semanales

Arquitectura de un vistazo

flowchart TB
    U[Navegador del usuario] --> CF[Proxy de Cloudflare]
    CF --> T

    subgraph H[VPS en Hetzner]
      T[Traefik]
      B[Stack público base]
      N[Gateway de Nanobot]
      D[ddg-mcp]
      W[Vigilancia CVE semanal]
    end

    T --> B
    T --> N
    N --> D
    N --> OAI[API de OpenAI]
    W --> N
    W --> D

1) Aprovisiona el servidor en Hetzner

Configuración que utilicé:

  • Región: Alemania / NBG1
  • Imagen: Ubuntu 24.04
  • Tipo: CAX11
  • IPv4 pública: activada
  • Firewall de entrada: TCP 22, 80, 443
  • Firewall de salida: permitir todo

Añade tu clave pública SSH durante la creación.

2) Primer acceso y endurecimiento del host

Haz bootstrap con root una vez y después pasa a un usuario con sudo.

ssh -i <SSH_KEY_PATH> root@<SERVER_IP>

apt-get update
apt-get install -y fail2ban ufw unattended-upgrades ca-certificates curl gnupg

ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#\?KbdInteractiveAuthentication .*/KbdInteractiveAuthentication no/' /etc/ssh/sshd_config
systemctl restart ssh

systemctl enable --now fail2ban

Después:

  • crea <SSH_USER> con sudo
  • desactiva el login SSH de root
  • usa solo autenticación por clave a partir de ahora

3) Mantén protección SSH con fail2ban

cat >/etc/fail2ban/jail.local <<'EOF2'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
backend = systemd

[sshd]
enabled = true
port = 22
logpath = %(sshd_log)s
EOF2

systemctl restart fail2ban
fail2ban-client status sshd

4) Instala Docker Engine + plugin de Compose

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg

. /etc/os-release
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  ${VERSION_CODENAME} stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null

apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker

5) Configura DNS y TLS de Cloudflare

Apunta ambos dominios de apps a la IPv4 del VPS y mantén el proxy activado:

  • <PUBLIC_APP_DOMAIN> -> IPv4 del VPS (proxied)
  • <NANOBOT_DOMAIN> -> IPv4 del VPS (proxied)

Modo SSL/TLS de Cloudflare: Full (strict).

Traefik gestiona los certificados con ACME DNS challenge usando un token de Cloudflare.

sequenceDiagram
    participant Browser as Navegador
    participant CF as Cloudflare
    participant T as Traefik
    participant App as Servicio destino

    Browser->>CF: Petición HTTPS (cabecera Host)
    CF->>T: HTTPS proxied
    T->>T: Coincide router por labels host/path
    T->>App: Reenvía al servicio
    App-->>T: Respuesta
    T-->>CF: Respuesta + cabeceras de seguridad
    CF-->>Browser: Respuesta final

6) Despliega el stack público base

Desde el repo local al servidor:

tar -C hetzner/stacks -czf - basic-ok | ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'sudo tar xzf - -C /opt/stacks'
ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'sudo cp /opt/stacks/basic-ok/.env.example /opt/stacks/basic-ok/.env'
ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'cd /opt/stacks/basic-ok && sudo docker compose up -d'

Qué te da esto:

  • edge routing con Traefik
  • una página pública mínima
  • un patrón opcional de status API que puedes reutilizar en stacks posteriores

7) Despliega Nanobot + sidecar DuckDuckGo MCP

Yo despliego desde hetzner/stacks/nanobot-gateway:

  • nanobot-gateway: app principal (nanobot-ai)
  • ddg-mcp: sidecar proveedor de búsqueda
  • Ruta Traefik: <NANOBOT_DOMAIN>
tar -C hetzner/stacks -czf - nanobot-gateway | ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'sudo tar xzf - -C /opt/stacks'
ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'sudo cp /opt/stacks/nanobot-gateway/.env.example /opt/stacks/nanobot-gateway/.env'
ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> 'cd /opt/stacks/nanobot-gateway && sudo docker compose up -d --build'

Health check rápido:

curl -I https://<NANOBOT_DOMAIN>/health

Si pruebas localmente en el VPS vía 127.0.0.1, incluye Host. Sin eso, Traefik puede devolver 404 por diseño.

curl -sS -o /dev/null -w "%{http_code}\n" -H "Host: <NANOBOT_DOMAIN>" http://127.0.0.1/
flowchart LR
    L[Prueba local desde VPS] --> H{"¿Host header configurada?"}
    H -- No --> E[Router por defecto de Traefik: 404]
    H -- Sí --> R[Coincide router <NANOBOT_DOMAIN>]
    R --> S[Servicio Nanobot]

8) Endurece contenedores con production defaults

Estos patrones hicieron el sistema más seguro sin mucha complejidad:

  • sin ports: en servicios detrás de Traefik
  • read_only: true para sistemas de archivos en runtime cuando sea posible
  • cap_drop: [ALL]
  • security_opt: [no-new-privileges:true]
  • tmpfs para rutas temporales con escritura
  • mantener health endpoints internos fuera del public routing
flowchart TD
    A[Baseline de endurecimiento de contenedores] --> B[Sin puertos publicados directamente]
    A --> C[Rootfs en solo lectura]
    A --> D[Eliminar capacidades Linux]
    A --> E[no-new-privileges]
    A --> F[tmpfs para /tmp]
    A --> G[Rutas públicas solo para paths necesarios]

9) Chequeos de seguridad que sí ejecuto

Parchar y escanear

sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y

sudo trivy image --scanners vuln --severity HIGH,CRITICAL nanobot-gateway:latest
sudo trivy image --scanners vuln --severity HIGH,CRITICAL ddg-mcp:latest

Chequeos de sanidad en runtime

docker ps
sudo fail2ban-client status sshd

10) Vigilancia CVE semanal automatizada con systemd

Añadí un script + timer que hace esto cada semana:

  1. reconstruir con actualización de base upstream (--pull)
  2. reiniciar stack
  3. ejecutar escaneos de Trivy
  4. registrar si CVE-2026-0861 sigue apareciendo
flowchart TD
    T[systemd timer] --> S[nanobot_os_watch.sh]
    S --> P[docker compose build --pull]
    P --> U[docker compose up -d]
    U --> V[Escaneos Trivy]
    V --> J[journalctl + logs del servicio]

Instalación:

scp -i <SSH_KEY_PATH> hetzner/scripts/nanobot_os_watch.sh <SSH_USER>@<SERVER_IP>:/tmp/
scp -i <SSH_KEY_PATH> hetzner/systemd/nanobot-os-watch.service <SSH_USER>@<SERVER_IP>:/tmp/
scp -i <SSH_KEY_PATH> hetzner/systemd/nanobot-os-watch.timer <SSH_USER>@<SERVER_IP>:/tmp/

ssh -i <SSH_KEY_PATH> <SSH_USER>@<SERVER_IP> '
  sudo install -m 0755 /tmp/nanobot_os_watch.sh /usr/local/bin/nanobot_os_watch.sh
  sudo install -m 0644 /tmp/nanobot-os-watch.service /etc/systemd/system/nanobot-os-watch.service
  sudo install -m 0644 /tmp/nanobot-os-watch.timer /etc/systemd/system/nanobot-os-watch.timer
  sudo systemctl daemon-reload
  sudo systemctl enable --now nanobot-os-watch.timer
'

11) Coste mínimo de esto

Si ya tienes dominio, el coste mensual base es sobre todo el VPS. Fecha de referencia de precios: 6 de marzo de 2026.

Base fija (mínimo)

  • Hetzner CAX11: desde 4,49 €/mes (sin IVA) en precios Alemania/Finlandia.
  • Cloudflare DNS + proxy en plan Free: $0/mes para este setup.
  • Traefik, Docker, fail2ban, Trivy (OSS) y ddg-mcp: $0 de coste de licencia.

Así que el suelo práctico es el coste del servidor (más IVA si aplica), asumiendo que:

  • ya tienes un dominio
  • te mantienes dentro de las features free-tier usadas aquí

Costes variables

  • Uso de API de OpenAI: pago por uso según tokens y modelo elegido.
  • Renovación de dominio: solo si necesitas comprar o renovar uno.
  • Extras opcionales: backups, snapshots, servidor más grande o add-ons de pago de Cloudflare.
pie title Forma del coste mensual mínimo
    "VPS Hetzner (fijo)" : 90
    "Todo lo demás (puede ser ~0 en baseline)" : 10

12) Checklist listo para publicar

Antes de compartir públicamente, paso esta lista rápida:

  • reemplazar placeholders (<SERVER_IP>, <SSH_USER>, dominios)
  • confirmar que ambos dominios devuelven 200 por HTTPS
  • verificar estado del jail de fail2ban
  • verificar que no haya valores sensibles de entorno en el artículo
  • dejar solo comandos probados en una terminal limpia

Cierre

Este setup es intencionalmente aburrido.

Es barato, repetible y fácil de depurar porque cada pieza es visible: Cloudflare, labels de Traefik, Docker Compose y timers de systemd. Para mi caso de uso, ese tradeoff es exactamente el correcto.

También me da un camino limpio para crecer: puedo seguir añadiendo más aplicaciones detrás del mismo borde Traefik + Cloudflare sin rediseñar toda la plataforma.

Y lo mejor: para decisiones de Arquitectura y DevOps, tengo a gpt-5.3-codex como copiloto.