Servidor personal: Debian + Docker, no FreeBSD
By B.E. Alejandro
Contexto
Este post reemplaza una entrada anterior que describía el plan original: FreeBSD, jails, bastilleBSD. Ese plan no se ejecutó así. Lo que terminó corriendo en la máquina reusada (hostname pcale) es Debian 13 (Trixie) con todo containerizado en Docker, un stack por servicio en docker-compose.yml separados, sin hypervisor ni jails.
La razón del cambio fue práctica: Docker Compose me da aislamiento por servicio sin la curva de aprendizaje adicional de jails/bhyve, y el ecosistema de imágenes ya resueltas (linuxserver, imágenes oficiales) redujo tiempo de instalación a casi cero comparado con compilar todo desde ports.
Infraestructura base
- SO: Debian 13 (Trixie), kernel 6.12.x
- Actualizaciones:
unattended-upgradespara parches de seguridad automáticos - Todo lo demás vive en contenedores — cada servicio tiene su propio
docker-compose.ymlbajo/home/ale/<servicio>/, así config, volúmenes y upgrades quedan aislados por app
Acceso y red
No hay puertos abiertos al público en el router. El acceso normal es vía Tailscale: cada servicio interno se publica con un hostname propio y HTTPS real usando Tailscale Services:
Esto da URLs como https://nextcloud.tail32b955.ts.net, con certificado válido, sin recordar puertos. Un par de servicios sí salen a internet público, pero vía Cloudflare Tunnel (cloudflared, conexión saliente únicamente, nada expuesto en el router) — el caso principal es PeerTube en tube.richard69.lat.
Perímetro:
- SSH solo con clave, llave de seguridad física (YubiKey),
X11Forwardingdesactivado ufwen default-deny, la mayoría de servicios restringidos a LAN (192.168.68.0/24) + Tailscale (100.0.0.0/8)fail2banen el jail de SSH (maxretry 3, ban 24h)- Vaultwarden llegó a tener un hostname público en el tunnel; se sacó por completo y ahora es Tailscale-only
Servicios corriendo
Nube y datos personales
- Nextcloud — almacenamiento personal, también sirve como fuente de la biblioteca de Calibre-web (apunta directo a
Documents/Librossin duplicar archivos) - Immich — fotos
- Vaultwarden — gestor de contraseñas (servidor compatible con Bitwarden), solo por Tailscale
- Calibre-web — lector/biblioteca de ebooks
Media
- Jellyfin — servidor de medios (películas, series, música)
- qBittorrent + Radarr + Sonarr + Prowlarr — el stack *arr completo: indexers → búsqueda → descarga → importación, aterrizando directo en la biblioteca de Jellyfin, que se refresca sola al importar
- Navidrome — streaming de música
- spotiflac, AudioMuse AI — utilidades de música
- PeerTube — instancia propia y federada de video (
tube.richard69.lat), con una imagen parcheada a mano (ver abajo)
Todo el lado de descargas (qBittorrent, Radarr, Sonarr, Prowlarr, spotiflac) corre con network_mode: container:mullvad-exit-vpn, compartiendo el namespace de red de un único contenedor gluetun, así ese tráfico —y solo ese— sale por un exit de Mullvad vía WireGuard.
Un extra que armé sobre esto: un script que convierte un canal de PeerTube seguido en una biblioteca de Jellyfin de archivos .strm (punteros a la URL del HLS del video original). Jellyfin los reproduce en directo desde la instancia origen sin que el archivo toque el disco del servidor.
Utilidades
- Glance — dashboard único: bookmarks, RSS, clima, chequeos de salud de cada servicio, buscador
- 4get — metabuscador privado, también el buscador embebido en Glance
- ntfy — notificaciones push self-hosted (alertas del servidor, watchdog de la VPN, etc.)
- Uptime Kuma / Beszel — monitoreo, con umbrales de alerta mandando a ntfy
- Prosody — mensajería XMPP
- Forgejo — Git self-hosted (donde vive este mismo blog)
Problemas encontrados (y soluciones)
network_mode: container:X no se re-resuelve solo. Docker fija esa referencia a un container ID en el momento de crear el contenedor. Si gluetun se recrea (docker compose up --force-recreate, o cualquier reinicio del stack), todo lo que comparte su netns (qBittorrent, Radarr, Sonarr, Prowlarr, spotiflac) queda apuntando a un namespace huérfano. El síntoma es engañoso: el contenedor se ve Up (healthy) y su proceso interno escucha bien desde adentro, pero nada llega desde afuera. Fix: force-recreate de cada contenedor afectado, no alcanza con docker restart.
*La ruta de descarga de un arr no puede ser su propia root folder. Si la categoría de qBittorrent apunta al mismo path que el root folder de Radarr/Sonarr, la app no puede distinguir “todavía bajando” de “ya importado” y tira DownloadClientRootFolderCheck. Solución: carpetas de staging separadas (/media/downloads/movies, /media/downloads/shows), Radarr/Sonarr importan por hardlink desde ahí al root folder real.
ntfy en iOS necesita un base-url alcanzable. iOS no puede recibir contenido completo en el push directamente (limitación de APNs) — el teléfono hace polling al base-url del propio servidor después de recibir un push genérico de “new message”. Si ese base-url apunta a un dominio muerto, la notificación queda vacía sin ningún error visible en ningún lado.
PeerTube solo acepta thumbnails remotos en JPEG. El validador de videos federados de PeerTube rechaza en silencio cualquier video cuyo thumbnail remoto sea PNG o WebP. Lo resolví con un parche chico horneado en una imagen Docker custom — hay que reconstruirla (no solo docker compose pull) en cada actualización de PeerTube o el parche desaparece sin avisar.
Qué falta por hacer
- Cloudflare Access delante de Uptime Kuma si algún día se tunelea al público
- Reglas de
ufwpara un par de servicios más nuevos (4get, AudioMuse) que hoy solo responden desde localhost - Un manual real de recuperación ante desastres — por ahora ese conocimiento vive en mis notas, no en un documento
Idea central
El objetivo cambió de “aprender FreeBSD a fondo” a “tener servicios propios corriendo de forma confiable, con el mínimo de superficie expuesta al público”. Docker Compose y Tailscale resultaron el camino más corto ahí. FreeBSD y jails siguen siendo un proyecto que me interesa, pero para otro momento — ver el post de OpenBSD para la versión de “control total” aplicada a algo más chico (el hospedaje de este mismo sitio).