Servidor privado autoalojado en NUC (Docker, LUKS, DDNS/TLS sobre red propia)

Escribo en Markdown, y necesitaba que ese material fuera privado por diseño hasta el momento en que yo decidiera publicarlo. Ningún servicio en la nube ofrece esa garantía: siempre hay un tercero con capacidad técnica de leer lo que guardas. Así que monté el mío. Levanté un servidor de Joplin (aplicación de notas en Markdown, de código abierto y con cifrado extremo a extremo) sobre un NUC doméstico modesto, un Intel Celeron J3455 con 8 GB de RAM y Linux Mint, encendido 24/7. Redacto y edito sincronizado entre portátil, sobremesa y móvil, y solo cuando el artículo está prácticamente cerrado lo importo a WordPress para el acabado final en Gutenberg.

La arquitectura resultante: acceso HTTPS remoto sobre un hostname propio, cifrado extremo a extremo (el servidor no puede leer las notas ni queriendo), y los datos en un volumen LUKS, ilegibles aunque alguien se llevara el disco físicamente, con copias de seguridad en un segundo volumen cifrado e independiente. El volumen se abre solo al arrancar mediante una unidad de systemd, y admite tres vías de desbloqueo distintas: passphrase de emergencia, un keyfile en una unidad USB, y el keyfile automático del arranque.

Exponer ese servidor a internet desde una conexión residencial obligó a resolver dos problemas de red encadenados. El primero, mantener un hostname estable pese a tener IP dinámica, se resolvió con DDNS, después de descartar un primer proveedor cuyo dominio comunitario no permitía automatizar los registros DNS. El segundo era más terco: mi ISP bloquea los puertos 80 y 443 entrantes, y Let’s Encrypt valida por defecto con el método HTTP-01, que consiste precisamente en recibir una conexión entrante por el puerto 80. El síntoma era un timeout opaco de Caddy al pedir el certificado, y lo revelador fue entender que el timeout no era mío: era el servidor de Let’s Encrypt intentando alcanzarme sin conseguirlo. La salida fue cambiar a validación DNS-01, que demuestra la propiedad del dominio publicando un registro TXT temporal en lugar de esperar una conexión, con lo que el certificado se obtiene sin abrir un solo puerto. El servicio quedó publicado en un puerto no estándar mediante port forwarding en el router, con Caddy como proxy inverso terminando el TLS.

El problema más difícil apareció al mover PostgreSQL, la base de datos donde Joplin guarda notas y adjuntos en binario, desde el disco de sistema a la partición grande de datos, formateada en NTFS. PostgreSQL exige permisos Unix y bloqueo de archivos reales, cosas que NTFS no le puede dar. La solución fue crear una imagen de bucle (loopback) sobre esa partición: un archivo que se comporta como un disco virtual, formateado en ext4 y cifrado con LUKS. PostgreSQL recibe así un sistema de archivos Linux legítimo sin tener que abandonar el disco NTFS existente, y de paso el volumen quedó consolidado como almacén cifrado único para todos los servicios que vinieron después.

Al recrear ese volumen desde cero en una sesión posterior, PostgreSQL se negó a inicializar. El volumen parecía vacío, y ahí estaba la trampa: al formatear en ext4 se crea automáticamente un directorio lost+found en la raíz, y PostgreSQL exige que su directorio de datos esté completamente vacío antes de inicializar, de modo que abortaba nada más encontrarlo. La causa no estaba en PostgreSQL, sino en el comportamiento de ext4 al crear el sistema de archivos, que es justo donde nadie mira. Ninguno de los dos hace nada incorrecto; simplemente asumen cosas incompatibles. Lo resolví declarando la variable PGDATA en el Docker Compose, apuntando a un subdirectorio dentro del punto de montaje en lugar de a su raíz: ese subdirectorio nace vacío en la primera inicialización y no hereda el lost+found. Sin esa variable, PostgreSQL no arranca sobre un volumen ext4 recién formateado. No hay alternativa.

El servidor fue creciendo hasta alojar varios servicios más (un gestor de contactos CalDAV/CardDAV, sincronización de archivos), todos sobre ese mismo volumen cifrado. Como el NUC no tiene monitor y lo administro en remoto por NoMachine y xRDP, me topé con un fallo escurridizo: xRDP moría al instante de iniciar sesión, sin dejar un solo error útil en los logs. Mi primera hipótesis fue que NoMachine y xRDP se pisaban al usar el mismo número de pantalla (:10), así que cerré NoMachine del todo y probé con xRDP a solas. El fallo se repitió idéntico, lo que descartó esa vía limpiamente. Días antes, otro problema en la misma máquina (un gestor de archivos que fallaba al lanzarse en remoto) me había obligado a entender que, en un equipo headless con reconexiones constantes, el entorno D-Bus de la sesión de usuario no se mantiene sincronizado con la sesión gráfica activa. Reconocí el mismo patrón aquí, y al revisar el script de arranque de sesión (startwm.sh) encontré la causa: apuntaba a un bus D-Bus compartido entre todas las sesiones remotas en lugar de crear uno privado por sesión. Un bus compartido puede arrastrar estado obsoleto de un intento anterior fallido, y ese choque de registro mata la sesión antes de que llegue a imprimir nada. Sustituir esa línea por dbus-launch --exit-with-session da a cada sesión un bus nuevo y aislado, lo que elimina la colisión por diseño en lugar de esquivarla.