[{"content":" El problema # Tenía contenedores Docker corriendo servicios críticos en mi servidor doméstico: Nextcloud, bases de datos, aplicaciones personalizadas. Necesitaba backups, pero no quería enviar datos sin cifrar a internet. Tampoco quería instalar agentes adicionales o configuraciones complicadas. Decidí usar restic con Wireguard.\nLa solución que implementé # La idea es simple: restic hace backup del volumen Docker cifrado, lo envía a través de un túnel Wireguard (red privada) hacia un NAS o servidor remoto que también tengo en otra ubicación.\nRequisitos # Docker y Docker Compose funcionando Wireguard ya configurado entre servidor y almacenamiento remoto restic instalado en el servidor principal Acceso SSH o local al almacenamiento remoto Paso 1: Preparar el almacenamiento remoto # En el servidor remoto, cree un directorio para los backups:\nmkdir -p /mnt/backups/docker-restic chmod 700 /mnt/backups/docker-restic Inicialicé el repositorio restic:\nrestic init --repo /mnt/backups/docker-restic Esto me pidió una contraseña. La guardé en un gestor de contraseñas.\nPaso 2: Configurar credenciales en el servidor principal # En el servidor con Docker, creé un archivo de configuración para restic:\ncat \u0026gt; ~/.restic-env \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; export RESTIC_REPOSITORY=\u0026#34;rest:http://wg-storage.local/backups/docker-restic\u0026#34; export RESTIC_PASSWORD=\u0026#34;tu-contraseña-fuerte-aqui\u0026#34; export RESTIC_CACHE_DIR=\u0026#34;/var/cache/restic\u0026#34; EOF chmod 600 ~/.restic-env Usé rest:http:// porque configuré un servidor REST en el almacenamiento remoto usando rest-server. Si prefieres sftp:\nexport RESTIC_REPOSITORY=\u0026#34;sftp:wg-storage.local:/mnt/backups/docker-restic\u0026#34; Paso 3: Script de backup # Creé un script que backupea los volúmenes de Docker:\n#!/bin/bash set -e source ~/.restic-env BACKUP_PATHS=( \u0026#34;/var/lib/docker/volumes/nextcloud-data/_data\u0026#34; \u0026#34;/var/lib/docker/volumes/postgres-data/_data\u0026#34; \u0026#34;/opt/docker-apps\u0026#34; ) echo \u0026#34;[$(date)] Iniciando backup de Docker...\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log for path in \u0026#34;${BACKUP_PATHS[@]}\u0026#34;; do if [ -d \u0026#34;$path\u0026#34; ]; then restic backup \u0026#34;$path\u0026#34; \\ --tag \u0026#34;docker-backup\u0026#34; \\ --tag \u0026#34;$(date +%Y-%m-%d)\u0026#34; \\ \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 fi done # Limpiar snapshots antiguos (mantener últimas 30) restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune \\ \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 echo \u0026#34;[$(date)] Backup completado\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log Guardé el script en /opt/backup-docker.sh y le di permisos:\nchmod 755 /opt/backup-docker.sh Paso 4: Automatizar con cron # Agregué una entrada a crontab para ejecutar el backup cada día a las 2 AM:\n0 2 * * * /opt/backup-docker.sh Paso 5: Verificar cifrado y conectividad # Ejecuté el backup manualmente la primera vez:\n/opt/backup-docker.sh Verifiqué que los datos llegaron cifrados al almacenamiento remoto:\nls -lah /mnt/backups/docker-restic/ Comprobé la conectividad Wireguard durante el backup:\nwg show restic snapshots Consideraciones importantes # Cifrado: restic cifra todo por defecto. Los datos viajan encriptados por Wireguard y se almacenan cifrados. Bandwidth: El primer backup es grande. En mi caso tardó horas. Los incrementales son mucho más rápidos. Monitoreo: Agregué alertas en caso de fallos del backup verificando el exit code del script. Recuperación: Probé restaurar un archivo para asegurarme que funciona: restic restore latest --target /tmp/test Resultado # Ahora tengo backups automáticos cifrados ejecutándose cada noche. Si algo falla en mi servidor, puedo restaurar los contenedores Docker desde el almacenamiento remoto sin preocuparme por datos expuestos. La combinación de restic + Wireguard + Docker es simple, segura y automatizada.\n","date":"June 29, 2026","externalUrl":null,"permalink":"/posts/backups-automaticos-y-cifrados-de-docker-con-restic-hacia-almacenamiento-remoto-via-wireguard/","section":"Posts","summary":"El problema # Tenía contenedores Docker corriendo servicios críticos en mi servidor doméstico: Nextcloud, bases de datos, aplicaciones personalizadas. Necesitaba backups, pero no quería enviar datos sin cifrar a internet. Tampoco quería instalar agentes adicionales o configuraciones complicadas. Decidí usar restic con Wireguard.\nLa solución que implementé # La idea es simple: restic hace backup del volumen Docker cifrado, lo envía a través de un túnel Wireguard (red privada) hacia un NAS o servidor remoto que también tengo en otra ubicación.\n","title":"Backups automáticos y cifrados de Docker con restic hacia almacenamiento remoto vía Wireguard","type":"posts"},{"content":"Llevo tiempo queriendo mejorar mi estrategia de backups en el servidor doméstico. Tener todo en Docker está bien, pero necesitaba algo más robusto que simplemente copiar archivos. Restic + MinIO resultó ser la combinación perfecta: backups incrementales, deduplicación y un S3 local sin depender de servicios en la nube.\nPor qué esta combinación # Restic es incremental por defecto, solo almacena lo que cambió. MinIO corre en el mismo servidor y expone una API S3 compatible, lo que evita dependencias externas. PostgreSQL se respalda en volúmenes Docker que también necesitan protección. Todo automatizado con cron.\nInstalación base # Primero, restic:\nsudo apt-get install restic MinIO ya está corriendo en Docker. Si no lo tienes:\ndocker run -d \\ --name minio \\ -p 9000:9000 \\ -p 9001:9001 \\ -e MINIO_ROOT_USER=minioadmin \\ -e MINIO_ROOT_PASSWORD=tupassword \\ -v /data/minio:/data \\ minio/minio server /data --console-address \u0026#34;:9001\u0026#34; Luego creo un usuario y bucket específicos para backups en la consola de MinIO (puerto 9001).\nConfigurar restic con MinIO # Inicializo el repositorio:\nexport AWS_ACCESS_KEY_ID=backup-user export AWS_SECRET_ACCESS_KEY=tu-clave-secreta export RESTIC_REPOSITORY=s3:http://localhost:9000/backups export RESTIC_PASSWORD=tu-password-restic restic init Guardo estas variables en un archivo seguro que solo el usuario root puede leer:\n# /root/.restic-env export AWS_ACCESS_KEY_ID=backup-user export AWS_SECRET_ACCESS_KEY=tu-clave-secreta export RESTIC_REPOSITORY=s3:http://localhost:9000/backups export RESTIC_PASSWORD=tu-password-restic chmod 600 /root/.restic-env Backup de PostgreSQL # PostgreSQL en Docker requiere un dump antes de respaldar. Creo un script:\n#!/bin/bash # /root/backup-postgres.sh set -e source /root/.restic-env BACKUP_DIR=\u0026#34;/tmp/postgres-backup\u0026#34; mkdir -p $BACKUP_DIR # Dump de la base de datos docker exec postgres-container pg_dump -U postgres -d tu_database \u0026gt; \\ $BACKUP_DIR/database-$(date +%s).sql # Backup con restic restic backup $BACKUP_DIR --tag postgres # Limpio rm -rf $BACKUP_DIR Backup de volúmenes Docker # Para los volúmenes de datos:\n#!/bin/bash # /root/backup-volumes.sh set -e source /root/.restic-env # Respalda volúmenes específicos montándolos docker run --rm -v mi_volumen:/data \\ -v /root:/root:ro \\ -w /data \\ alpine/restic:latest backup /data --tag docker-volumes Mejor aún, respaldo directamente la ruta donde Docker almacena los volúmenes:\nrestic backup /var/lib/docker/volumes/mi_volumen/_data --tag docker-volumes Automatización con cron # Creo un script maestro que ejecuta ambos backups:\n#!/bin/bash # /root/backup-all.sh source /root/.restic-env /root/backup-postgres.sh 2\u0026gt;\u0026amp;1 | logger -t restic-pg /root/backup-volumes.sh 2\u0026gt;\u0026amp;1 | logger -t restic-vol # Purgar snapshots antiguos (mantener 7 días) restic forget --keep-daily 7 --keep-monthly 3 --prune chmod +x /root/backup-all.sh En crontab:\n# Cron - ejecuta a las 2 AM diariamente 0 2 * * * /root/backup-all.sh Verificación y mantenimiento # Para ver el estado de los backups:\nrestic snapshots restic stats Para probar una restauración (sin tocar nada):\nrestic restore latest --target /tmp/test-restore Lo que aprendí # La deduplicación de restic es potente; después del primer backup, los incrementales son mínimos. MinIO ocupa poco espacio si configurás bien su limpieza de versiones antiguas. El dump de PostgreSQL debe hacerse antes del backup, no hay forma de respaldar la base activa sin riesgo de corrupción.\nEste setup me da tranquilidad. Si algo falla en el servidor, tengo backups automáticos, versionados y recuperables en cuestión de minutos.\n","date":"June 26, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-postgresql-y-docker-con-restic-hacia-minio/","section":"Posts","summary":"Llevo tiempo queriendo mejorar mi estrategia de backups en el servidor doméstico. Tener todo en Docker está bien, pero necesitaba algo más robusto que simplemente copiar archivos. Restic + MinIO resultó ser la combinación perfecta: backups incrementales, deduplicación y un S3 local sin depender de servicios en la nube.\nPor qué esta combinación # Restic es incremental por defecto, solo almacena lo que cambió. MinIO corre en el mismo servidor y expone una API S3 compatible, lo que evita dependencias externas. PostgreSQL se respalda en volúmenes Docker que también necesitan protección. Todo automatizado con cron.\n","title":"Automatizar backups incrementales de PostgreSQL y Docker con restic hacia MinIO","type":"posts"},{"content":"","date":"June 26, 2026","externalUrl":null,"permalink":"/tags/minio/","section":"Tags","summary":"","title":"Minio","type":"tags"},{"content":" El problema # Tenía varios contenedores Docker corriendo en mi servidor doméstico con datos que no podía perder. Los backups manuales no escalan, así que decidí automatizar todo con rsync y cron. Aquí documento lo que funcionó.\nLa arquitectura # Mi setup:\nServidor local: Host Docker con volúmenes en /var/lib/docker/volumes/ Almacenamiento remoto: NAS en red con SSH habilitado Herramientas: rsync para sincronización incremental, cron para programación, sha256sum para verificación Preparación # Primero, configura acceso SSH sin contraseña desde el host Docker al NAS:\nssh-keygen -t ed25519 -f ~/.ssh/backup_key -N \u0026#34;\u0026#34; ssh-copy-id -i ~/.ssh/backup_key usuario@nas.local Verifica que funciona:\nssh -i ~/.ssh/backup_key usuario@nas.local \u0026#34;ls -la\u0026#34; Script de backup incremental # Crea /usr/local/bin/docker-backup.sh:\n#!/bin/bash BACKUP_USER=\u0026#34;usuario\u0026#34; BACKUP_HOST=\u0026#34;nas.local\u0026#34; BACKUP_PATH=\u0026#34;/mnt/backups/docker\u0026#34; SSH_KEY=\u0026#34;/root/.ssh/backup_key\u0026#34; LOG_FILE=\u0026#34;/var/log/docker-backup.log\u0026#34; MANIFEST=\u0026#34;/var/log/docker-backup-manifest.txt\u0026#34; # Volúmenes a respaldar (ajusta según tus necesidades) VOLUMES=(\u0026#34;postgres_data\u0026#34; \u0026#34;nextcloud_data\u0026#34; \u0026#34;app_config\u0026#34;) # Función para logging log() { echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] $1\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; } log \u0026#34;=== Iniciando backup incremental ===\u0026#34; # Crear directorio remoto si no existe ssh -i \u0026#34;$SSH_KEY\u0026#34; \u0026#34;$BACKUP_USER@$BACKUP_HOST\u0026#34; \u0026#34;mkdir -p $BACKUP_PATH\u0026#34; # Función para respaldar volumen backup_volume() { local volume=$1 local volume_path=\u0026#34;/var/lib/docker/volumes/${volume}/_data\u0026#34; local remote_path=\u0026#34;$BACKUP_USER@$BACKUP_HOST:$BACKUP_PATH/$volume\u0026#34; if [ ! -d \u0026#34;$volume_path\u0026#34; ]; then log \u0026#34;ERROR: Volumen $volume no encontrado en $volume_path\u0026#34; return 1 fi log \u0026#34;Respaldando volumen: $volume\u0026#34; # rsync incremental rsync -av --delete \\ -e \u0026#34;ssh -i $SSH_KEY\u0026#34; \\ \u0026#34;$volume_path/\u0026#34; \\ \u0026#34;$remote_path/\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then log \u0026#34;✓ Backup completado: $volume\u0026#34; return 0 else log \u0026#34;✗ Error en backup: $volume\u0026#34; return 1 fi } # Respaldar todos los volúmenes FAILED=0 for volume in \u0026#34;${VOLUMES[@]}\u0026#34;; do backup_volume \u0026#34;$volume\u0026#34; || ((FAILED++)) done # Generar manifiesto con checksums log \u0026#34;Generando manifiesto de integridad...\u0026#34; { echo \u0026#34;Manifiesto de Backup - $(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)\u0026#34; echo \u0026#34;==========================================\u0026#34; for volume in \u0026#34;${VOLUMES[@]}\u0026#34;; do echo \u0026#34;Volumen: $volume\u0026#34; find \u0026#34;/var/lib/docker/volumes/${volume}/_data\u0026#34; -type f -exec sha256sum {} \\; 2\u0026gt;/dev/null echo \u0026#34;\u0026#34; done } \u0026gt; \u0026#34;$MANIFEST\u0026#34; # Respaldar el manifiesto también scp -i \u0026#34;$SSH_KEY\u0026#34; \u0026#34;$MANIFEST\u0026#34; \u0026#34;$BACKUP_USER@$BACKUP_HOST:$BACKUP_PATH/manifest-$(date +%Y%m%d).txt\u0026#34; if [ $FAILED -eq 0 ]; then log \u0026#34;=== Backup completado exitosamente ===\u0026#34; exit 0 else log \u0026#34;=== Backup completado con $FAILED errores ===\u0026#34; exit 1 fi Hazlo ejecutable:\nchmod +x /usr/local/bin/docker-backup.sh Verificación de integridad # Crea /usr/local/bin/docker-backup-verify.sh:\n#!/bin/bash BACKUP_USER=\u0026#34;usuario\u0026#34; BACKUP_HOST=\u0026#34;nas.local\u0026#34; BACKUP_PATH=\u0026#34;/mnt/backups/docker\u0026#34; SSH_KEY=\u0026#34;/root/.ssh/backup_key\u0026#34; LOG_FILE=\u0026#34;/var/log/docker-backup-verify.log\u0026#34; log() { echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] $1\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; } log \u0026#34;=== Iniciando verificación de integridad ===\u0026#34; # Descargar manifiesto más reciente LATEST_MANIFEST=$(ssh -i \u0026#34;$SSH_KEY\u0026#34; \u0026#34;$BACKUP_USER@$BACKUP_HOST\u0026#34; \\ \u0026#34;ls -t $BACKUP_PATH/manifest-*.txt | head -1\u0026#34;) if [ -z \u0026#34;$LATEST_MANIFEST\u0026#34; ]; then log \u0026#34;ERROR: No se encontró manifiesto remoto\u0026#34; exit 1 fi scp -i \u0026#34;$SSH_KEY\u0026#34; \u0026#34;$BACKUP_USER@$BACKUP_HOST:$LATEST_MANIFEST\u0026#34; /tmp/manifest-verify.txt # Extraer y verificar checksums while IFS= read -r line; do if [[ $line =~ ^[a-f0-9]{64} ]]; then hash=$(echo \u0026#34;$line\u0026#34; | awk \u0026#39;{print $1}\u0026#39;) file=$(echo \u0026#34;$line\u0026#34; | awk \u0026#39;{print $2}\u0026#39;) if [ -f \u0026#34;$file\u0026#34; ]; then local_hash=$(sha256sum \u0026#34;$file\u0026#34; | awk \u0026#39;{print $1}\u0026#39;) if [ \u0026#34;$hash\u0026#34; != \u0026#34;$local_hash\u0026#34; ]; then log \u0026#34;ALERTA: Checksum inconsistente en $file\u0026#34; fi fi fi done \u0026lt; /tmp/manifest-verify.txt log \u0026#34;=== Verificación completada ===\u0026#34; Programación con cron # Edita el crontab:\ncrontab -e Añade estas líneas:\n# Backup diario a las 2:00 AM 0 2 * * * /usr/local/bin/docker-backup.sh # Verificación semanal los domingos a las 3:00 AM 0 3 * * 0 /usr/local/bin/docker-backup-verify.sh # Enviar log por correo si hay errores 0 4 * * * [ -f /var/log/docker-backup.log ] \u0026amp;\u0026amp; tail -20 /var/log/docker-backup.log | mail -s \u0026#34;Backup Docker - Resumen\u0026#34; admin@example.com Monitoreo # Revisa los logs regularmente:\ntail -f /var/log/docker-backup.log Para investigar problemas de rsync específicos:\nrsync -av --dry-run /var/lib/docker/volumes/postgres_data/_data usuario@nas.local:/mnt/backups/docker/postgres_data/ Resultado # Ahora tengo backups incrementales automáticos cada noche. rsync solo transfiere cambios, lo que ahorra ancho de banda. La verificación de integridad me alerta si algo se corrompe. Es simple, func\n","date":"June 24, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-a-almacenamiento-remoto-con-rsync-y-verificacion-de-integridad/","section":"Posts","summary":"El problema # Tenía varios contenedores Docker corriendo en mi servidor doméstico con datos que no podía perder. Los backups manuales no escalan, así que decidí automatizar todo con rsync y cron. Aquí documento lo que funcionó.\nLa arquitectura # Mi setup:\nServidor local: Host Docker con volúmenes en /var/lib/docker/volumes/ Almacenamiento remoto: NAS en red con SSH habilitado Herramientas: rsync para sincronización incremental, cron para programación, sha256sum para verificación Preparación # Primero, configura acceso SSH sin contraseña desde el host Docker al NAS:\n","title":"Automatizar backups incrementales de volúmenes Docker a almacenamiento remoto con rsync y verificación de integridad","type":"posts"},{"content":" El problema # Después de perder datos por un fallo de almacenamiento, aprendí que los contenedores Docker son efímeros. Los volúmenes que albergan bases de datos, configuraciones y archivos importantes necesitan protección. Backups manuales no escalan. Necesitaba algo automatizado, eficiente y verificable.\nElegí restic porque hace backups incrementales (solo guarda cambios), es agnóstico del destino (local, S3, B2, etc.) y verifica integridad con hash. Combinado con cron, tengo una solución sólida.\nInstalación # En mi servidor Debian, instalo restic:\nsudo apt-get update \u0026amp;\u0026amp; sudo apt-get install -y restic Para backups locales, creo el directorio de destino:\nsudo mkdir -p /mnt/backup-storage sudo chmod 700 /mnt/backup-storage Si usas almacenamiento remoto (recomendado), configura credenciales. Yo uso un bucket S3 local con Minio, pero la sintaxis es similar.\nInicializar repositorio # Restic necesita un repositorio inicializado. Lo hago una sola vez:\nrestic -r /mnt/backup-storage init Me pide una contraseña. La guardo en un gestor seguro. Sin ella, los backups son inútiles.\nScript de backup # Creo /usr/local/bin/backup-docker-volumes.sh:\n#!/bin/bash # Variables RESTIC_REPO=\u0026#34;/mnt/backup-storage\u0026#34; RESTIC_PASSWORD=\u0026#34;tu_contraseña_aqui\u0026#34; LOG_FILE=\u0026#34;/var/log/docker-backup.log\u0026#34; DOCKER_VOLUMES_PATH=\u0026#34;/var/lib/docker/volumes\u0026#34; # Timestamp TIMESTAMP=$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) # Exportar variables para restic export RESTIC_REPOSITORY=\u0026#34;$RESTIC_REPO\u0026#34; export RESTIC_PASSWORD=\u0026#34;$RESTIC_PASSWORD\u0026#34; # Función de logging log() { echo \u0026#34;[$TIMESTAMP] $1\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; } log \u0026#34;=== Iniciando backup de volúmenes Docker ===\u0026#34; # Backup de volúmenes restic backup \u0026#34;$DOCKER_VOLUMES_PATH\u0026#34; \\ --tag docker-volumes \\ --exclude=\u0026#39;lost+found\u0026#39; \\ --exclude=\u0026#39;.git\u0026#39; \\ \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then log \u0026#34;✓ Backup completado exitosamente\u0026#34; else log \u0026#34;✗ Error durante el backup\u0026#34; exit 1 fi # Limpiar snapshots antiguos (mantener últimos 30 días) restic forget --keep-daily 30 --prune \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 log \u0026#34;=== Limpieza de snapshots antiguos completada ===\u0026#34; # Verificar integridad restic check \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then log \u0026#34;✓ Verificación de integridad exitosa\u0026#34; else log \u0026#34;✗ Error en verificación de integridad\u0026#34; exit 1 fi Hago el script ejecutable:\nsudo chmod +x /usr/local/bin/backup-docker-volumes.sh Programar con cron # Edito la tabla cron del root:\nsudo crontab -e Agrego esta línea para ejecutar backups diariamente a las 2 AM:\n0 2 * * * /usr/local/bin/backup-docker-volumes.sh Para backups cada 6 horas:\n0 */6 * * * /usr/local/bin/backup-docker-volumes.sh Verifico que se ejecute:\nsudo grep CRON /var/log/syslog | tail -5 Recuperación ante desastres # Cuando necesito restaurar:\n# Listar snapshots disponibles restic snapshots # Restaurar un snapshot específico restic restore [snapshot-id] --target /mnt/restore-point Si un contenedor se corrompió, detengo el stack, restauro el volumen y reinicio:\ndocker-compose down restic restore [snapshot-id] --target /var/lib/docker/volumes/mi-volumen docker-compose up -d Buenas prácticas aprendidas # Nunca guardes la contraseña en texto plano en cron. Yo uso un archivo /root/.restic-pass con permisos 600. Monitorea los logs. Configura alertas si los backups fallan. Prueba restauraciones regularmente. Un backup no probado es un backup que no funciona. Guarda backups fuera de tu servidor. La redundancia local no te protege de hardware fallido. Cifra los backups si los envías a cloud. Restic lo hace por defecto. Resultado # Ahora duermo tranquilo. Mis volúmenes Docker se respaldan cada 6 horas, solo guardo cambios (restic es eficiente), y puedo recuperar cualquier cosa en minutos. El destino de mis backups es un NAS en otra ubicación, así que aunque el servidor explote, mis datos están seguros.\n","date":"June 22, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-con-restic-y-cron/","section":"Posts","summary":"El problema # Después de perder datos por un fallo de almacenamiento, aprendí que los contenedores Docker son efímeros. Los volúmenes que albergan bases de datos, configuraciones y archivos importantes necesitan protección. Backups manuales no escalan. Necesitaba algo automatizado, eficiente y verificable.\nElegí restic porque hace backups incrementales (solo guarda cambios), es agnóstico del destino (local, S3, B2, etc.) y verifica integridad con hash. Combinado con cron, tengo una solución sólida.\n","title":"Automatizar backups incrementales de volúmenes Docker con restic y cron","type":"posts"},{"content":"","date":"June 22, 2026","externalUrl":null,"permalink":"/tags/recuperaci%C3%B3n-desastres/","section":"Tags","summary":"","title":"Recuperación-Desastres","type":"tags"},{"content":" El problema # Tenía PostgreSQL corriendo en Docker con volúmenes que contenían datos críticos. Los backups manuales no escalan y respaldar todo cada vez es ineficiente. Necesitaba algo automatizado, incremental y que verificara que los datos llegaran bien al NAS.\nPor qué restic # Restic maneja snapshots incrementales nativamente, comprime, cifra y permite verificar la integridad de los backups remotos sin descargar todo. Perfecto para un servidor doméstico donde el ancho de banda importa.\nPrerequisitos # Docker con PostgreSQL corriendo NAS accesible por SSH o SMB restic instalado en el host pg_dump o acceso al contenedor PostgreSQL Script de automatización (systemd timer o cron) Paso 1: Preparar el volumen de datos # Primero, identifico dónde está el volumen de PostgreSQL:\ndocker volume inspect postgres_data Esto me muestra la ruta. En mi caso: /var/lib/docker/volumes/postgres_data/_data\nPaso 2: Configurar acceso al NAS # Monto el NAS en el host. Uso SSH con restic:\nexport RESTIC_REPOSITORY=\u0026#34;sftp:usuario@192.168.1.100:/backups/postgres\u0026#34; export RESTIC_PASSWORD=\u0026#34;contraseña-fuerte\u0026#34; Guardo estas variables en un archivo seguro:\ncat \u0026gt; /etc/restic/env.sh \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; export RESTIC_REPOSITORY=\u0026#34;sftp:usuario@192.168.1.100:/backups/postgres\u0026#34; export RESTIC_PASSWORD=\u0026#34;tu-password\u0026#34; EOF chmod 600 /etc/restic/env.sh Paso 3: Crear script de backup # El script hace tres cosas: dump de la BD, backup con restic y verificación:\n#!/bin/bash set -e source /etc/restic/env.sh POSTGRES_CONTAINER=\u0026#34;postgres\u0026#34; BACKUP_DIR=\u0026#34;/tmp/pg_backups\u0026#34; LOG_FILE=\u0026#34;/var/log/restic-postgres.log\u0026#34; # Crear directorio temporal mkdir -p $BACKUP_DIR # Dump de PostgreSQL docker exec $POSTGRES_CONTAINER pg_dump -U postgres nombrebd | \\ gzip \u0026gt; $BACKUP_DIR/db_$(date +%Y%m%d_%H%M%S).sql.gz # Backup incremental con restic echo \u0026#34;[$(date)] Iniciando backup...\u0026#34; \u0026gt;\u0026gt; $LOG_FILE restic backup $BACKUP_DIR \\ --tag postgres \\ --tag incremental \\ 2\u0026gt;\u0026amp;1 \u0026gt;\u0026gt; $LOG_FILE # Verificar integridad echo \u0026#34;[$(date)] Verificando integridad...\u0026#34; \u0026gt;\u0026gt; $LOG_FILE restic check --read-data 2\u0026gt;\u0026amp;1 \u0026gt;\u0026gt; $LOG_FILE if [ $? -eq 0 ]; then echo \u0026#34;[$(date)] Backup verificado correctamente\u0026#34; \u0026gt;\u0026gt; $LOG_FILE rm -rf $BACKUP_DIR else echo \u0026#34;[$(date)] ERROR: Verificación fallida\u0026#34; \u0026gt;\u0026gt; $LOG_FILE exit 1 fi Lo guardo en /usr/local/bin/backup-postgres.sh y lo hago ejecutable:\nchmod +x /usr/local/bin/backup-postgres.sh Paso 4: Automatizar con systemd timer # Creo un servicio y un timer para ejecutar diariamente:\ncat \u0026gt; /etc/systemd/system/restic-postgres.service \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; [Unit] Description=Restic Backup PostgreSQL After=docker.service Wants=restic-postgres.timer [Service] Type=oneshot ExecStart=/usr/local/bin/backup-postgres.sh StandardOutput=journal StandardError=journal EOF cat \u0026gt; /etc/systemd/system/restic-postgres.timer \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; [Unit] Description=Restic PostgreSQL Timer Requires=restic-postgres.service [Timer] OnCalendar=daily OnCalendar=03:00 Persistent=true [Install] WantedBy=timers.target EOF Activo los timers:\nsystemctl daemon-reload systemctl enable --now restic-postgres.timer Paso 5: Monitorear y verificar # Chequeo el estado:\nsystemctl status restic-postgres.timer journalctl -u restic-postgres.service -n 50 Para ver snapshots disponibles en el NAS:\nrestic snapshots Verificación periódica # Cada mes ejecuto una verificación completa de lectura:\nrestic check --read-data-subset=10% Conclusión # Con esto tengo backups incrementales diarios, cifrados, verificados automáticamente y almacenados en el NAS. El overhead es mínimo porque restic solo sube lo que cambió. Si algo falla, systemd me avisa en el log y puedo investigar.\nEl punto crítico: restic verifica que los datos se escribieron bien sin descargar todo. Eso vale oro en un backup.\n","date":"June 19, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-en-postgresql-con-restic-y-verificacion-en-nas/","section":"Posts","summary":"El problema # Tenía PostgreSQL corriendo en Docker con volúmenes que contenían datos críticos. Los backups manuales no escalan y respaldar todo cada vez es ineficiente. Necesitaba algo automatizado, incremental y que verificara que los datos llegaran bien al NAS.\nPor qué restic # Restic maneja snapshots incrementales nativamente, comprime, cifra y permite verificar la integridad de los backups remotos sin descargar todo. Perfecto para un servidor doméstico donde el ancho de banda importa.\n","title":"Automatizar backups incrementales de volúmenes Docker en PostgreSQL con restic y verificación en NAS","type":"posts"},{"content":"","date":"June 19, 2026","externalUrl":null,"permalink":"/tags/nas/","section":"Tags","summary":"","title":"Nas","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/802.1x/","section":"Tags","summary":"","title":"802.1x","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/active-directory/","section":"Tags","summary":"","title":"Active-Directory","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/adcs/","section":"Tags","summary":"","title":"Adcs","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/administraci%C3%B3n-de-sistemas/","section":"Categories","summary":"","title":"Administración De Sistemas","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/an%C3%A1lisis-de-incidentes/","section":"Categories","summary":"","title":"Análisis De Incidentes","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/certificados/","section":"Tags","summary":"","title":"Certificados","type":"tags"},{"content":" El problema: ese aviso que aparece cada vez # Si administras un FortiGate y tienes usuarios conectándose por VPN SSL, ya conoces el escenario: el cliente FortiClient o el navegador muestra un aviso de \u0026ldquo;certificado no confiable\u0026rdquo; antes de establecer la conexión. Los usuarios hacen clic en \u0026ldquo;Continuar de todas formas\u0026rdquo; sin leerlo, y el área de seguridad pierde credibilidad cada vez que alguien les pregunta si eso es normal.\nEl motivo es simple: FortiGate viene de fábrica con un certificado autofirmado (Fortinet_CA_SSL o similar). Los navegadores y sistemas operativos no incluyen esa CA en su almacén de confianza, así que lo marcan como no confiable. No es un fallo del appliance; es el comportamiento correcto del cliente ante una CA desconocida.\nLa solución permanente es reemplazar ese certificado autofirmado por uno emitido por una autoridad en la que los clientes ya confíen.\nTres opciones para resolver el problema # Antes de entrar en detalle, conviene conocer las tres vías disponibles:\nOpción 1 — CA interna con ADCS (recomendada si tienes dominio Windows): usas tu propia infraestructura de clave pública. Los equipos unidos al dominio ya confían en tu CA automáticamente. Sin coste adicional y con control total sobre la renovación.\nOpción 2 — Let\u0026rsquo;s Encrypt con ACME nativo (FortiGate 6.4 en adelante): válida si el FortiGate tiene un FQDN público resolvible desde internet. FortiOS implementa el protocolo ACME y renueva el certificado automáticamente. No sirve para appliances detrás de NAT sin nombre público.\nOpción 3 — Certificado comercial: compras un certificado a una CA reconocida (DigiCert, Sectigo, etc.). El proceso de importación es idéntico a la opción 1, pero sin necesitar ADCS. Tiene coste anual y depende de un tercero para la renovación.\nEn la mayoría de entornos corporativos con Active Directory, la opción 1 es la más limpia: aprovecha infraestructura ya existente, los equipos del dominio confían en la CA sin configuración adicional y tienes control total.\nOpción 1 paso a paso: ADCS como CA firmante # Requisitos previos # Windows Server con el rol Active Directory Certificate Services (ADCS) instalado y configurado como CA empresarial (Enterprise CA). Acceso de administrador al FortiGate. El FortiGate debe tener un FQDN o nombre DNS interno que resuelva correctamente desde los clientes. Puede ser solo un nombre interno, no necesita ser público. Paso 1 — Generar el CSR en FortiGate # Un CSR (Certificate Signing Request) es la solicitud que el FortiGate genera para pedirle a la CA que emita un certificado. Contiene la clave pública y los datos de identidad del appliance.\nEn la interfaz de FortiGate:\nVe a System \u0026gt; Certificates \u0026gt; Generate. Configura los campos: Campo Valor recomendado Certificate Name fortigate-vpn (nombre interno, sin espacios) Key Type RSA Key Size 2048 bits (mínimo; 4096 si la CA lo soporta) Digest Algorithm SHA-256 Common Name (CN) FQDN del FortiGate, p. ej. vpn.empresa.local Organization (O) Nombre de tu organización Locality / State / Country Datos reales (la CA puede requerirlos) En Subject Alternative Names añade:\nTipo DNS: el FQDN que usan los clientes para conectarse. Tipo IP: la IP del interfaz SSL-VPN, si los clientes se conectan por IP. Los SAN son obligatorios para que Chrome y navegadores modernos acepten el certificado; el CN solo ya no es suficiente.\nHaz clic en OK. FortiGate descarga automáticamente un archivo .csr.\nPaso 2 — Firmar el CSR en ADCS # Transfiere el archivo .csr a un servidor con acceso a la CA. Hay dos métodos:\nMétodo A — línea de comandos (recomendado para automatizar):\ncertreq -submit -attrib \u0026#34;CertificateTemplate:WebServer\u0026#34; fortigate.csr fortigate.cer Si tienes varias CAs en el entorno, especifica cuál usar:\ncertreq -submit -config \u0026#34;CA-SERVER\\Nombre-CA\u0026#34; -attrib \u0026#34;CertificateTemplate:WebServer\u0026#34; fortigate.csr fortigate.cer El resultado es el archivo fortigate.cer con el certificado firmado.\nMétodo B — interfaz web de ADCS:\nAbre http://CA-SERVER/certsrv en un navegador. Haz clic en Solicitar un certificado. Elige Enviar una solicitud de certificado avanzada. Selecciona Enviar una solicitud de certificado mediante un archivo PKCS#10 codificado en Base 64. Pega el contenido del .csr y en Plantilla de certificado elige Servidor web. Descarga el certificado en formato Base 64 (.cer). Si el botón de descarga pide reiniciar la solicitud, usa el método de línea de comandos; es más fiable con CSRs generados fuera de Windows.\nPaso 3 — Importar el certificado firmado en FortiGate # En FortiGate, ve a System \u0026gt; Certificates \u0026gt; Import \u0026gt; Local Certificate. Selecciona el archivo fortigate.cer que generó la CA. FortiGate lo asocia automáticamente con el CSR que generaste antes porque las claves coinciden. Verifica que el nuevo certificado aparece en la lista con el nombre que configuraste y que el campo Issuer muestra el nombre de tu CA interna. Paso 4 — Asignar el certificado a SSL-VPN # El certificado importado no entra en servicio hasta que lo asignas explícitamente:\nVe a VPN \u0026gt; SSL-VPN Settings. En el campo Server Certificate, selecciona el certificado que acabas de importar (fortigate-vpn en el ejemplo). Guarda los cambios. FortiGate recarga el servicio SSL; los clientes conectados en ese momento pueden notar una desconexión breve. Distribución del certificado raíz via GPO # El certificado del FortiGate está firmado por tu CA interna, pero si los equipos cliente no tienen el certificado raíz de esa CA en su almacén de confianza, seguirán viendo el aviso. La distribución automática via Directiva de Grupo (GPO) resuelve esto de forma centralizada.\nEn el Controlador de Dominio o desde la consola GPMC:\nCrea o edita una GPO aplicada a los equipos que usan la VPN. Navega a: Computer Configuration \u0026gt; Windows Settings \u0026gt; Security Settings \u0026gt; Public Key Policies \u0026gt; Trusted Root Certification Authorities Haz clic derecho \u0026gt; Import e importa el certificado raíz de tu CA (.cer de la CA, no del FortiGate). Aplica y cierra. Los equipos recibirán el certificado raíz en el siguiente ciclo de actualización de directivas (normalmente 90 minutos, o puedes forzarlo con gpupdate /force). Para los equipos que no están en el dominio (dispositivos personales, móviles), tendrás que distribuir el certificado raíz manualmente o mediante MDM.\nVerificación # Una vez completado el proceso, verifica que todo funciona correctamente:\nDesde FortiClient:\nAbre FortiClient e intenta conectarte a la VPN SSL. Si el proceso fue correcto, la conexión se establece sin ningún aviso de certificado. La diferencia respecto al comportamiento anterior es inmediata.\nDesde el navegador:\nAccede a la interfaz de administración del FortiGate o al portal web SSL-VPN por HTTPS. El candado del navegador debe aparecer cerrado y sin advertencias. Haz clic en él para confirmar que el emisor es tu CA interna.\nValidación con certutil:\nEn cualquier equipo Windows del dominio:\ncertutil -verify fortigate.cer La salida debe terminar con CertUtil: -verify command completed successfully. Si hay errores de cadena, el problema está en la distribución del certificado raíz.\nPara comprobar la cadena completa:\ncertutil -verify -urlfetch fortigate.cer Esto verifica también los puntos de distribución CRL (listas de revocación) configurados en la CA.\nPor qué ADCS es la opción más limpia en entornos con dominio # La ventaja real no está en la firma del certificado en sí, sino en la distribución de confianza. Cuando una organización tiene un dominio Windows, los equipos unidos al dominio reciben el certificado raíz de la CA interna de forma automática desde el momento en que se unen al dominio. No hay que hacer nada adicional: la GPO que distribuye el certificado raíz ya existe por defecto en la mayoría de instalaciones de ADCS empresarial.\nComparado con las alternativas:\nLet\u0026rsquo;s Encrypt: requiere que el FortiGate tenga un FQDN público y responda al desafío ACME. En redes corporativas con FortiGates detrás de NAT o con nombre solo interno, esto no es viable. Además, la renovación cada 90 días depende de que el proceso automático funcione sin interrupciones. Certificado comercial: resuelve el problema para cualquier dispositivo (incluso los que no están en el dominio), pero tiene coste anual y el ciclo de renovación es manual o semi-manual. Útil cuando hay clientes externos que no controlas. Con ADCS, el ciclo de vida completo (emisión, renovación, revocación) queda dentro de tu infraestructura. La renovación antes de la expiración sigue el mismo proceso: nuevo CSR desde FortiGate, firma en ADCS, reimportación y reasignación. Algunos equipos automatizan este paso con scripts que invocan certreq contra la CA y suben el certificado resultado via la API de FortiGate.\nConclusión # El aviso de certificado no confiable no es un problema estético: erosiona la confianza de los usuarios en los controles de seguridad y, en el peor caso, los entrena a ignorar avisos SSL. Resolverlo con ADCS en entornos con Active Directory es directo, sin coste y deja la infraestructura en mejor estado que antes.\nEl proceso tiene cuatro pasos reales: generar el CSR en FortiGate, firmarlo en ADCS, importar el certificado firmado de vuelta y asignarlo a SSL-VPN. La distribución via GPO hace que la experiencia del usuario sea transparente desde el primer ciclo de actualización de directivas.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/certificados-ssl-fortigate-adcs-windows-server/","section":"Posts","summary":"El problema: ese aviso que aparece cada vez # Si administras un FortiGate y tienes usuarios conectándose por VPN SSL, ya conoces el escenario: el cliente FortiClient o el navegador muestra un aviso de “certificado no confiable” antes de establecer la conexión. Los usuarios hacen clic en “Continuar de todas formas” sin leerlo, y el área de seguridad pierde credibilidad cada vez que alguien les pregunta si eso es normal.\n","title":"Certificados SSL en FortiGate con ADCS: elimina el aviso de certificado no confiable","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/cve/","section":"Tags","summary":"","title":"Cve","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/diagn%C3%B3stico/","section":"Categories","summary":"","title":"Diagnóstico","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/diagn%C3%B3stico/","section":"Tags","summary":"","title":"Diagnóstico","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/eap-tls/","section":"Tags","summary":"","title":"Eap-Tls","type":"tags"},{"content":"Cuando un cliente pregunta si puede añadir TOTP al WiFi corporativo para cumplir con su política de MFA, la respuesta técnicamente correcta es \u0026ldquo;sí, pero no deberías\u0026rdquo;. Este artículo explica por qué TOTP es prácticamente inutilizable en 802.1X, cómo comparar los métodos EAP más comunes y cuál es la ruta recomendada hacia una autenticación WiFi robusta.\nEl problema de TOTP en redes inalámbricas # La intuición detrás de querer TOTP en WiFi es razonable: si lo usas en VPN y en aplicaciones web, ¿por qué no en la red inalámbrica? El problema está en cómo funciona el supplicant WiFi.\nEn cualquier dispositivo —Windows, iOS, Android— el cliente WiFi guarda las credenciales para reconectar automáticamente. Cada vez que el dispositivo pierde señal, cambia de punto de acceso o sale de suspensión, el supplicant intenta la autenticación sin intervención del usuario. Ese comportamiento es el que hace que el WiFi empresarial sea usable.\nAhora añade un OTP con validez de 30 segundos a esa ecuación:\nEl usuario introduce su usuario, contraseña y el código TOTP del momento. La autenticación funciona. El dispositivo se desconecta (ascensor, parking, suspensión). El supplicant intenta reconectar automáticamente con las mismas credenciales. El código TOTP ya ha caducado. La autenticación falla. El usuario ve que no tiene WiFi sin entender por qué. El helpdesk empieza a recibir llamadas. Los usuarios aprenden a desactivar el WiFi y usar datos móviles. La política de seguridad ha conseguido exactamente lo contrario de lo que pretendía.\nEl método que haría esto posible técnicamente es PEAP/GTC (Generic Token Card), que transmite la contraseña en texto plano dentro del túnel TLS externo. A diferencia de MSCHAPv2, GTC no aplica hash a las credenciales, por lo que el servidor RADIUS puede verificar un OTP en tiempo real. Pero sin caché de credenciales, cada reconexión requiere intervención manual del usuario. En la práctica, es inaceptable para cualquier entorno de producción.\nComparativa de métodos EAP # Antes de elegir un método EAP hay que entender qué hace cada uno con las credenciales y qué implica para la infraestructura:\nMétodo Autenticación interna Requiere cert. cliente Compatible con TOTP Experiencia de usuario PEAP/MSCHAPv2 NTLM hash No No Buena PEAP/GTC Texto plano (en túnel TLS) No Teóricamente sí Mala (sin caché) EAP-TLS Certificado X.509 Sí N/A Excelente PEAP/MSCHAPv2 es el punto de partida habitual. El dispositivo abre un túnel TLS con el servidor RADIUS usando el certificado del servidor, y dentro negocia MSCHAPv2 con el hash NTLMv2 de la contraseña. No requiere certificado en el cliente, solo un par usuario/contraseña. La reconexión automática funciona perfectamente porque las credenciales son estáticas.\nLa debilidad de MSCHAPv2 es que el hash NTLMv2 es vulnerable a ataques offline si alguien captura el intercambio y el diccionario de contraseñas es débil. Además, si el dispositivo no valida correctamente el certificado del servidor RADIUS, es posible un ataque man-in-the-middle con un RADIUS falso.\nEAP-TLS elimina estas debilidades por diseño: no hay contraseña que capturar ni hash que atacar.\nEAP-TLS: el certificado como factor de autenticación # En EAP-TLS, ambos lados presentan un certificado: el servidor RADIUS demuestra al cliente que es legítimo, y el dispositivo demuestra al RADIUS que está autorizado. La autenticación es mutua por construcción.\nEl certificado de dispositivo cumple la función de \u0026ldquo;algo que tienes\u0026rdquo; de forma nativa. No necesitas añadir un segundo factor porque el certificado ya es ese segundo factor: está vinculado a un dispositivo concreto, fue emitido por tu CA interna, y solo puede usarse desde ese dispositivo (la clave privada nunca sale del TPM o del almacén de certificados del sistema operativo).\nVentajas prácticas:\nNo hay credenciales que phishear. Un atacante no puede engañar a un usuario para que \u0026ldquo;introduzca su contraseña WiFi\u0026rdquo; porque no existe tal contraseña. No hay credenciales que rotar. Cuando un empleado se va, revocas su certificado. El dispositivo queda excluido de la red en el siguiente intento de autenticación, sin necesidad de cambiar contraseñas de grupo ni tocar la GPO de WiFi. La reconexión automática funciona perfectamente. El certificado no caduca en 30 segundos. El coste es la infraestructura: necesitas una CA interna que emita certificados a los dispositivos. En entornos con Active Directory, esto significa ADCS (Active Directory Certificate Services), y el proceso de distribución se automatiza con autoenrollment via GPO.\nImplementación básica con FreeRADIUS + ADCS # Certificados en ADCS # El primer paso es crear una plantilla de certificado en ADCS para autenticación de equipos. La plantilla debe tener:\nPropósito: Autenticación de cliente (OID 1.3.6.1.5.5.7.3.2) Nombre del sujeto: Construido desde Active Directory (nombre DNS del equipo) Permisos de inscripción: Equipos del dominio → Leer + Inscribir automáticamente Con esta plantilla publicada, el autoenrollment vía GPO distribuye los certificados sin intervención del usuario. Los equipos del dominio solicitan y renuevan sus propios certificados automáticamente.\nConfiguración de FreeRADIUS # El módulo EAP de FreeRADIUS necesita apuntar a los ficheros de certificado del servidor y activar la verificación del cliente:\neap { default_eap_type = tls tls { private_key_file = /etc/ssl/private/radius.key certificate_file = /etc/ssl/certs/radius.pem CA_file = /etc/ssl/certs/ca.pem dh_file = /etc/ssl/certs/dh.pem verify { client = yes } } } El parámetro CA_file debe apuntar a la cadena completa de tu CA interna —raíz e intermedias si las hay—. FreeRADIUS verificará que el certificado presentado por el cliente esté firmado por esa CA y no haya sido revocado. Para la revocación en tiempo real puedes configurar CRL o OCSP en el mismo bloque tls.\nEl certificado del servidor (radius.pem) debe estar firmado por la misma CA o una CA de confianza que los clientes conozcan. Si usas ADCS, lo más limpio es emitir también el certificado del servidor RADIUS desde allí.\nGPO para distribución automática # Dos objetos de directiva de grupo cubren el despliegue completo:\nGPO de autoenrollment (Computer Configuration → Windows Settings → Security Settings → Public Key Policies):\nActivar el autoenrollment de certificados de equipo Renovar automáticamente los certificados caducados Actualizar los certificados que usan plantillas de certificado GPO del perfil WiFi (Computer Configuration → Windows Settings → Security Settings → Wireless Network Policies):\nSSID corporativo Tipo de seguridad: WPA2-Enterprise Método EAP: Microsoft: Smart Card u otro certificado CA de validación: tu CA interna (marcada como confianza) Sin prompt al usuario (silent authentication) Con ambas GPO aplicadas, un equipo que se une al dominio recibe su certificado y su perfil WiFi automáticamente. El usuario no configura nada: la red aparece y conecta sola.\nRuta de migración recomendada # No todos los entornos pueden pasar a EAP-TLS de un día para otro. La infraestructura PKI requiere planificación: definir la jerarquía de CA, decidir si la raíz es online u offline, establecer el ciclo de vida de los certificados y preparar el proceso de revocación.\nUna ruta pragmática:\nFase 1 — Hoy: Desplegar WPA2-Enterprise con PEAP/MSCHAPv2 si aún no lo tienes. Es infinitamente mejor que WPA2-PSK (contraseña compartida). Asegúrate de que los clientes validan el certificado del servidor RADIUS para evitar ataques MitM.\nFase 2 — Medio plazo: Montar ADCS si no existe o auditar la CA existente. Publicar la plantilla de autoenrollment para equipos. Hacer una prueba piloto con EAP-TLS en una VLAN separada.\nFase 3 — Objetivo: Migrar el SSID corporativo a EAP-TLS. Mantener temporalmente un SSID secundario con PEAP/MSCHAPv2 para dispositivos de invitados o equipos no gestionados.\nLo que nunca deberías hacer: activar PEAP/GTC con TOTP en producción. El impacto en la experiencia de usuario garantiza que la política no se cumplirá —los usuarios buscarán alternativas— y el helpdesk pagará las consecuencias.\nConclusión # TOTP es una herramienta excelente para autenticación web y VPN, donde el usuario inicia la sesión de forma explícita. En WiFi empresarial, el modelo de reconexión automática lo hace incompatible con la realidad operativa.\nLa respuesta correcta al \u0026ldquo;quiero MFA en el WiFi\u0026rdquo; no es añadir TOTP: es migrar a EAP-TLS, donde el certificado de dispositivo ya incorpora el segundo factor de forma transparente para el usuario y robusta para el equipo de seguridad.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/eap-tls-vs-peap-wifi-empresarial-802-1x/","section":"Posts","summary":"Cuando un cliente pregunta si puede añadir TOTP al WiFi corporativo para cumplir con su política de MFA, la respuesta técnicamente correcta es “sí, pero no deberías”. Este artículo explica por qué TOTP es prácticamente inutilizable en 802.1X, cómo comparar los métodos EAP más comunes y cuál es la ruta recomendada hacia una autenticación WiFi robusta.\nEl problema de TOTP en redes inalámbricas # La intuición detrás de querer TOTP en WiFi es razonable: si lo usas en VPN y en aplicaciones web, ¿por qué no en la red inalámbrica? El problema está en cómo funciona el supplicant WiFi.\n","title":"EAP-TLS vs PEAP para WiFi empresarial: cuándo usar certificados y por qué TOTP no encaja en 802.1X","type":"posts"},{"content":"Configurar FreeRADIUS 3.x con autenticación PAM —especialmente combinando pam_google_authenticator, pam_winbind y fail2ban— genera un conjunto de errores que aparecen una y otra vez. Este artículo los recoge con causa exacta y solución directa, sin rodeos.\nTabla resumen # # Síntoma Causa raíz Solución rápida 1 Failed to change user id to \u0026quot;usuario\u0026quot; user=root en pam conf, freerad no puede hacer setuid user=freerad + chown freerad /etc/google-authenticator/ 2 user not found / getpwnam() failed Winbind sin idmap RID, los usuarios AD no tienen UID Unix Añadir bloques idmap config en smb.conf + reiniciar winbind 3 Secret file permissions (0644) are more permissive than 0600 Permisos demasiado abiertos en el fichero TOTP chmod 600 /etc/google-authenticator/* 4 Failed to create tempfile: Permission denied Directorio TOTP propiedad de root, freerad no puede escribir chown freerad:freerad /etc/google-authenticator/ 5 Contraseñas visibles en el log tras desactivarlas auth_badpass requiere reinicio, no reload; auth_log loguea independientemente systemctl restart freeradius + revisar módulo auth_log 6 No Auth-Type found: rejecting via Post-Auth-Type = Reject El virtual server no fuerza Auth-Type a PAP para rutas PAM Añadir update control { \u0026amp;Auth-Type := PAP } en authorize 7 fail2ban no arranca: Jail 'sshd' is not valid logpath duplicado en jail.local o jails de servicios inexistentes fail2ban-client -t + eliminar duplicados y jails inactivos Error 1: Failed to change user id to \u0026quot;usuario\u0026quot; # Síntoma # (0) pam: pam_authenticate: Failed to change user id to \u0026#34;usuario\u0026#34; (0) pam: ERROR: PAM auth for user \u0026#34;usuario\u0026#34; failed Causa # pam_google_authenticator.so se carga con el parámetro user=root, lo que obliga a PAM a hacer setuid(root) antes de leer el fichero TOTP. El proceso freeradius corre como usuario freerad y no tiene permiso para asumir la identidad de root.\nSolución # En /etc/pam.d/radiusd, cambiar user=root por user=freerad:\n# /etc/pam.d/radiusd auth required pam_google_authenticator.so secret=/etc/google-authenticator/${USER} user=freerad Asegurarse de que los ficheros TOTP son legibles por freerad:\nchown freerad /etc/google-authenticator/ ls -la /etc/google-authenticator/ Verificación # id freerad # uid=116(freerad) gid=125(freerad) ... ls -la /etc/google-authenticator/ # drwxr-xr-x freerad freerad . # -rw------- freerad freerad usuario Error 2: user not found / getpwnam() failed # Síntoma # pam_winbind(radiusd:auth): user \u0026#39;DOMINIO\\usuario\u0026#39; not found getpwnam() failed: No such user Causa # Winbind puede autenticar al usuario contra Active Directory (wbinfo -a funciona), pero si smb.conf no tiene una configuración idmap correcta para el dominio, getpwnam() no puede asignar un UID Unix al usuario AD. El sistema operativo no lo encuentra aunque las credenciales sean válidas.\nSolución # Añadir los bloques idmap config en /etc/samba/smb.conf:\n[global] # ... configuración existente ... # Backend por defecto para dominios no especificados idmap config * : backend = tdb idmap config * : range = 3000-7999 # Backend RID para el dominio corporativo idmap config DOMINIO : backend = rid idmap config DOMINIO : range = 10000-99999 Reiniciar winbind (no solo reload):\nsystemctl restart winbind systemctl status winbind Diagnóstico # # Listar usuarios del dominio wbinfo -u | grep usuario # Resolver usuario AD a entidad Unix getent passwd \u0026#39;DOMINIO\\usuario\u0026#39; # DOMINIO\\usuario:*:12345:10001:Nombre Apellido:/home/DOMINIO/usuario:/bin/bash Si getent no devuelve nada, el problema está en el rango idmap o en el backend. Si wbinfo -u tampoco funciona, el problema es previo: la unión al dominio o la conectividad con el DC.\nError 3: Secret file permissions (0644) are more permissive than 0600 # Síntoma # pam_google_authenticator(radiusd:auth): Secret file permissions (0644) are more permissive than 0600. (0) pam: ERROR: PAM auth for user \u0026#34;usuario\u0026#34; failed Causa # pam_google_authenticator comprueba los permisos del fichero TOTP antes de leerlo. Si son 0644 o más abiertos, rechaza el fichero por razones de seguridad. El error puede aparecer como autenticación fallida sin mayor detalle en los logs de freeradius, lo que dificulta el diagnóstico si no se ejecuta freeradius -X.\nSolución # chmod 600 /etc/google-authenticator/* ls -la /etc/google-authenticator/ # -rw------- freerad freerad usuario Consecuencia si no se corrige # pam_google_authenticator rechaza la autenticación silenciosamente desde la perspectiva de FreeRADIUS. El usuario recibe un Access-Reject sin causa aparente. Solo freeradius -X muestra el mensaje de permisos.\nError 4: Failed to create tempfile: Permission denied # Síntoma # pam_google_authenticator(radiusd:auth): Failed to create tempfile \u0026#34;/etc/google-authenticator/.usuario.XXXXXX\u0026#34;: Permission denied Causa # pam_google_authenticator escribe un fichero temporal en el mismo directorio donde está el secreto TOTP antes de actualizar el contador de uso único (para evitar replay attacks). Si el directorio /etc/google-authenticator/ pertenece a root y el proceso corre como freerad, la escritura falla.\nEste error es distinto del error 1: el setuid puede estar correctamente configurado con user=freerad, pero el directorio sigue siendo propiedad de root.\nSolución # chown freerad:freerad /etc/google-authenticator/ Verificar que los ficheros individuales siguen siendo 0600 después del cambio:\nls -la /etc/google-authenticator/ # drwxr-xr-x freerad freerad . # -rw------- freerad freerad usuario El directorio necesita permisos de escritura para freerad; los ficheros individuales solo necesitan lectura.\nError 5: Contraseñas visibles en el log tras configurar auth_badpass = no # Síntoma # Se configura auth_badpass = no en radiusd.conf esperando que las contraseñas fallidas dejen de aparecer en los logs, pero siguen siendo visibles.\nCausa # Hay dos causas independientes que pueden ocurrir simultáneamente:\nReinicio incompleto: el cambio en radiusd.conf requiere restart, no reload. Con reload, el proceso mantiene la configuración anterior en memoria. Módulo auth_log: si está habilitado en la sección authorize del virtual server, registra la petición —incluyendo atributos sensibles— independientemente de la configuración de auth_badpass. Solución # Reinicio completo:\nsystemctl restart freeradius # No usar: systemctl reload freeradius Verificar el módulo auth_log en el virtual server (normalmente /etc/freeradius/3.0/sites-enabled/default):\nauthorize { # Si este módulo está aquí, loguea independientemente de auth_badpass # auth_log ← comentar o eliminar si no se necesita ... } Confirmar que el cambio surtió efecto:\ngrep -n \u0026#39;auth_badpass\u0026#39; /etc/freeradius/3.0/radiusd.conf freeradius -X 2\u0026gt;\u0026amp;1 | grep -i \u0026#34;bad.*pass\\|log.*pass\u0026#34; Error 6: No Auth-Type found: rejecting via Post-Auth-Type = Reject # Síntoma # (0) No Auth-Type found: rejecting the user via Post-Auth-Type = Reject (0) Failed to authenticate the user La autenticación PAM funciona en pruebas directas (pamtester), pero FreeRADIUS rechaza sin llegar a invocarla.\nCausa # FreeRADIUS necesita saber qué método de autenticación usar. Si ningún módulo en la sección authorize establece Auth-Type, el servidor no sabe que debe delegar en PAM y rechaza la petición. Esto ocurre habitualmente cuando se migra de autenticación local a PAM sin actualizar el virtual server.\nSolución # En el virtual server activo (/etc/freeradius/3.0/sites-enabled/default), añadir en la sección authorize:\nauthorize { preprocess update control { \u0026amp;Auth-Type := PAP } pam ... } Y asegurarse de que el módulo pam está habilitado en la sección authenticate:\nauthenticate { Auth-Type PAP { pam } } Verificar que el módulo PAM está habilitado:\nls /etc/freeradius/3.0/mods-enabled/pam Si no existe, crear el enlace:\ncd /etc/freeradius/3.0/mods-enabled \u0026amp;\u0026amp; ln -s ../mods-available/pam pam Error 7: fail2ban no arranca — Jail 'sshd' is not valid # Síntoma # ERROR Failed during configuration: Have not found \u0026#39;logpath\u0026#39; option in \u0026#39;sshd\u0026#39; jail # o bien: ERROR Jail \u0026#39;sshd\u0026#39; is not valid systemctl start fail2ban falla o el servicio se inicia pero sin jails activos.\nCausa # Dos variantes comunes:\nlogpath duplicado: jail.local define logpath dos veces en la misma sección [sshd], lo que invalida la configuración del jail. Jails de servicios no instalados: si jail.local habilita jails para apache, vsftpd u otros servicios que no están presentes, fail2ban no encuentra los ficheros de log y falla. Solución # Validar la configuración antes de (re)iniciar el servicio:\nfail2ban-client -t # Si hay errores, los muestra con fichero y línea Editar /etc/fail2ban/jail.local y eliminar entradas duplicadas:\n[sshd] enabled = true port = ssh # Solo un logpath: logpath = %(sshd_log)s maxretry = 5 Comentar jails de servicios no instalados:\n# [apache-auth] # enabled = true # ... # [vsftpd] # enabled = true # ... Reiniciar tras validar:\nfail2ban-client -t \u0026amp;\u0026amp; systemctl restart fail2ban fail2ban-client status Herramientas de diagnóstico # Antes de revisar código o configuración, estas herramientas reducen el tiempo de diagnóstico a minutos:\nfreeradius -X — modo debug interactivo. Detener el servicio y ejecutar en primer plano para ver cada paso del procesamiento, incluyendo los mensajes de PAM que no aparecen en los logs normales:\nsystemctl stop freeradius freeradius -X radtest — enviar una petición de autenticación de prueba directamente desde la línea de comandos:\nradtest usuario contraseña+totp 127.0.0.1 0 secreto-compartido journalctl — seguir los logs del servicio en tiempo real:\njournalctl -u freeradius -f wbinfo -P — verificar que Winbind tiene conectividad con el controlador de dominio:\nwbinfo -P # checking the NETLOGON for domain[DOMINIO] dc connection ... succeeded fail2ban-client -t — validar la configuración completa de fail2ban sin iniciar el servicio:\nfail2ban-client -t Conclusión # La mayoría de estos errores comparten un patrón: el proceso freerad no tiene los permisos correctos sobre los recursos que necesita, o la configuración requiere un reinicio completo para surtir efecto. El orden de diagnóstico recomendado es:\nEjecutar freeradius -X para ver el error completo Verificar propietario y permisos con ls -la sobre los directorios afectados Confirmar que Winbind resuelve usuarios con getent passwd antes de depurar FreeRADIUS Validar configuraciones antes de aplicarlas (fail2ban-client -t) Reiniciar, no recargar, cuando se cambia configuración que afecta al proceso en memoria Con estos siete errores cubiertos y las herramientas de diagnóstico adecuadas, la mayoría de los problemas habituales en entornos FreeRADIUS + PAM se resuelven sin necesidad de escalar.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/errores-frecuentes-freeradius-pam-diagnostico/","section":"Posts","summary":"Configurar FreeRADIUS 3.x con autenticación PAM —especialmente combinando pam_google_authenticator, pam_winbind y fail2ban— genera un conjunto de errores que aparecen una y otra vez. Este artículo los recoge con causa exacta y solución directa, sin rodeos.\nTabla resumen # # Síntoma Causa raíz Solución rápida 1 Failed to change user id to \"usuario\" user=root en pam conf, freerad no puede hacer setuid user=freerad + chown freerad /etc/google-authenticator/ 2 user not found / getpwnam() failed Winbind sin idmap RID, los usuarios AD no tienen UID Unix Añadir bloques idmap config en smb.conf + reiniciar winbind 3 Secret file permissions (0644) are more permissive than 0600 Permisos demasiado abiertos en el fichero TOTP chmod 600 /etc/google-authenticator/* 4 Failed to create tempfile: Permission denied Directorio TOTP propiedad de root, freerad no puede escribir chown freerad:freerad /etc/google-authenticator/ 5 Contraseñas visibles en el log tras desactivarlas auth_badpass requiere reinicio, no reload; auth_log loguea independientemente systemctl restart freeradius + revisar módulo auth_log 6 No Auth-Type found: rejecting via Post-Auth-Type = Reject El virtual server no fuerza Auth-Type a PAP para rutas PAM Añadir update control { \u0026Auth-Type := PAP } en authorize 7 fail2ban no arranca: Jail 'sshd' is not valid logpath duplicado en jail.local o jails de servicios inexistentes fail2ban-client -t + eliminar duplicados y jails inactivos Error 1: Failed to change user id to \"usuario\" # Síntoma # (0) pam: pam_authenticate: Failed to change user id to \"usuario\" (0) pam: ERROR: PAM auth for user \"usuario\" failed Causa # pam_google_authenticator.so se carga con el parámetro user=root, lo que obliga a PAM a hacer setuid(root) antes de leer el fichero TOTP. El proceso freeradius corre como usuario freerad y no tiene permiso para asumir la identidad de root.\n","title":"Errores frecuentes en FreeRADIUS 3.x con PAM: guía de diagnóstico","type":"posts"},{"content":" Por qué proteger RADIUS con fail2ban # FreeRADIUS es el pilar de autenticación en muchas redes empresariales: VPNs, switches con 802.1X, Wi-Fi corporativo. El puerto 1812 UDP recibe constantemente intentos de credential stuffing, especialmente si la IP del servidor tiene alguna exposición pública. A diferencia de SSH, donde el atacante necesita llegar a un servicio TCP con negociación, RADIUS sobre UDP no tiene overhead de conexión: enviar miles de paquetes de autenticación cuesta casi nada al atacante.\nfail2ban resuelve esto de forma elegante: lee los logs de autenticación, detecta patrones de fallo repetidos y añade reglas de firewall temporales para bloquear el origen. El resultado es que un atacante que falla cinco veces queda bloqueado durante horas sin intervención manual.\nEste artículo asume FreeRADIUS 3.x sobre Ubuntu/Debian y fail2ban 0.11+. Para SSH, también se cubre el gotcha del logpath duplicado que impide arrancar el servicio.\nInstalación # fail2ban está en los repositorios oficiales:\napt update \u0026amp;\u0026amp; apt install fail2ban ufw -y systemctl enable fail2ban Si UFW no está activo aún, actívalo antes de continuar. fail2ban necesita que el backend de firewall esté operativo para añadir reglas:\nufw allow OpenSSH ufw enable Estructura de configuración: jail.local es la clave # Nunca editar /etc/fail2ban/jail.conf. Ese archivo lo sobreescribe el paquete en cada actualización. La práctica correcta es crear /etc/fail2ban/jail.local, que se fusiona con jail.conf en tiempo de carga y tiene precedencia sobre cualquier valor que defina.\nEl mismo principio aplica a los filtros: /etc/fail2ban/filter.d/ contiene los archivos .conf originales del paquete. Si necesitas modificar un filtro existente, crea un archivo .local con el mismo nombre junto al original.\nJail para SSH # La jail de SSH viene incluida y activa por defecto en muchas distribuciones, pero hay un error frecuente que hace que fail2ban no arranque: el parámetro logpath definido dos veces cuando se sobreescribe la jail en jail.local.\nEl bloque correcto para SSH es el siguiente:\n[sshd] enabled = true logpath = /var/log/auth.log Si en tu jail.local tienes una sección [sshd] y además la jail por defecto en jail.conf también define logpath, fail2ban 0.11+ trata la clave duplicada como error de configuración y se niega a arrancar. La solución es definir logpath solo en jail.local y dejar que sobreescriba el valor de jail.conf.\nPara verificar que la configuración es válida antes de reiniciar:\nfail2ban-client -t Si no aparece ningún error, la sintaxis es correcta.\nJail para FreeRADIUS # FreeRADIUS escribe los fallos de autenticación en /var/log/freeradius/radius.log. La jail personalizada usa un findtime más corto que la de SSH porque los ataques de credential stuffing son más agresivos en ráfagas cortas:\n[radius-login] enabled = true port = 1812 protocol = udp logpath = /var/log/freeradius/radius.log maxretry = 5 findtime = 300 bantime = 7200 filter = radius-login Parámetros destacados:\nprotocol = udp — fail2ban delega a UFW, que sí puede bloquear UDP por IP de origen. findtime = 300 — ventana de detección de 5 minutos: cinco fallos en ese periodo activan el bloqueo. bantime = 7200 — el origen queda baneado 2 horas. Más agresivo que el SSH porque un servidor RADIUS no tiene usuarios legítimos haciendo diez intentos seguidos. Filtro personalizado para RADIUS # fail2ban no incluye un filtro para FreeRADIUS por defecto. Hay que crearlo:\nnano /etc/fail2ban/filter.d/radius-login.conf [Definition] failregex = Login incorrect.*client \u0026lt;HOST\u0026gt; Auth: .*Login incorrect.*\\[\u0026lt;HOST\u0026gt;\\] ignoreregex = El placeholder \u0026lt;HOST\u0026gt; es la notación de fail2ban para capturar la IP del origen. Las dos expresiones cubren los dos formatos que usa FreeRADIUS 3.x en función de la versión y configuración del módulo pap/chap:\nLogin incorrect.*client \u0026lt;HOST\u0026gt; captura la forma más común donde la IP aparece en el campo client. Auth: .*Login incorrect.*\\[\u0026lt;HOST\u0026gt;\\] cubre el formato alternativo con la IP entre corchetes. Para verificar que el filtro funciona contra entradas reales del log antes de activar la jail:\nfail2ban-regex /var/log/freeradius/radius.log /etc/fail2ban/filter.d/radius-login.conf La salida muestra cuántas líneas coinciden. Si el contador es cero con un log que tiene fallos conocidos, hay que ajustar las expresiones.\nConfiguración completa de jail.local # El archivo resultante integra todo lo anterior con UFW como backend de baneo:\n# /etc/fail2ban/jail.local [DEFAULT] bantime = 3600 findtime = 600 maxretry = 5 banaction = ufw [sshd] enabled = true logpath = /var/log/auth.log [radius-login] enabled = true port = 1812 protocol = udp logpath = /var/log/freeradius/radius.log maxretry = 5 findtime = 300 bantime = 7200 filter = radius-login La sección [DEFAULT] establece los valores base para todas las jails. Cada jail puede sobrescribir los que necesite, como hace radius-login con findtime y bantime.\nbanaction = ufw indica a fail2ban que gestione los bloqueos mediante UFW en lugar de iptables directamente. Esto mantiene la coherencia: UFW es la herramienta de gestión de firewall en el sistema, y los baneos de fail2ban aparecen en ufw status junto con el resto de reglas.\nJails que no hay que activar sin verificar # Un error habitual al configurar fail2ban es activar jails para servicios que no están instalados. Si jail.local tiene [apache-auth] enabled = true pero Apache no está en el sistema, fail2ban buscará el logpath de Apache y fallará al arrancar porque el archivo no existe.\nLas jails problemáticas más habituales en un servidor sin esos servicios:\napache-auth, apache-badbots, apache-noscript vsftpd dovecot, postfix La regla es simple: solo activar jails de servicios que estén instalados y en ejecución en el sistema.\nAplicar la configuración y verificar # Tras guardar jail.local y el filtro radius-login.conf:\nsystemctl restart fail2ban systemctl status fail2ban Para ver el estado general de todas las jails activas:\nfail2ban-client status La salida muestra la lista de jails en ejecución. Para ver el detalle de una jail específica, incluyendo los IPs actualmente baneados:\nfail2ban-client status sshd fail2ban-client status radius-login La salida incluye el número total de fallos detectados, IPs baneadas en este momento y el histórico de baneos desde el último reinicio.\nPara desbanear manualmente una IP (por ejemplo, si un administrador se bloquea a sí mismo):\nfail2ban-client set radius-login unbanip 192.168.1.100 fail2ban-client set sshd unbanip 192.168.1.100 Seguimiento en los logs # fail2ban escribe su propia actividad en /var/log/fail2ban.log. Las entradas relevantes tienen este aspecto:\n2026-06-18 10:23:41,452 fail2ban.actions [1234]: NOTICE [radius-login] Ban 203.0.113.45 2026-06-18 10:23:41,460 fail2ban.actions [1234]: NOTICE [sshd] Ban 198.51.100.72 Para seguir los baneos en tiempo real:\ntail -f /var/log/fail2ban.log | grep Ban En infraestructura con volumen alto de intentos, es habitual ver docenas de baneos por hora en el puerto RADIUS. Eso confirma que el sistema funciona y cuantifica la presión que estaba recibiendo el servidor sin protección.\nConclusión # La combinación fail2ban + UFW cubre los dos vectores de autenticación más atacados en infraestructura de red: SSH para administración y RADIUS para acceso de usuarios. La configuración descrita en este artículo añade una capa de protección activa con un coste de mantenimiento prácticamente nulo una vez desplegada.\nEl punto más importante es el que más se pasa por alto: usar jail.local para toda la configuración propia y validar siempre con fail2ban-client -t antes de reiniciar el servicio. Los errores de configuración silenciosos —jails con logpath duplicado o servicios inexistentes— son la causa más habitual de que fail2ban arranque pero no proteja nada.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/fail2ban-freeradius-ssh-proteccion-fuerza-bruta-radius/","section":"Posts","summary":"Por qué proteger RADIUS con fail2ban # FreeRADIUS es el pilar de autenticación en muchas redes empresariales: VPNs, switches con 802.1X, Wi-Fi corporativo. El puerto 1812 UDP recibe constantemente intentos de credential stuffing, especialmente si la IP del servidor tiene alguna exposición pública. A diferencia de SSH, donde el atacante necesita llegar a un servicio TCP con negociación, RADIUS sobre UDP no tiene overhead de conexión: enviar miles de paquetes de autenticación cuesta casi nada al atacante.\n","title":"fail2ban para FreeRADIUS y SSH: bloqueo de fuerza bruta en infraestructura de red","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/flask/","section":"Tags","summary":"","title":"Flask","type":"tags"},{"content":"El 7 de junio de 2026 Fortinet confirmó lo que los investigadores de seguridad llevaban semanas documentando: una campaña de compromiso masivo contra dispositivos FortiGate que dejó 75.000 equipos afectados en 194 países. El ataque combinó una vulnerabilidad de autenticación SAML (CVE-2026-24858, CVSS 9.8) con un fallo en cómo FortiOS almacenaba las contraseñas durante el proceso de actualización. El resultado: 1.160 millones de intentos de autenticación en 29 días, credenciales de administrador en manos de actores de amenaza y acceso directo a redes corporativas a través de las mismas VPN diseñadas para protegerlas.\nEste artículo analiza qué falló técnicamente, por qué el vector de ataque era predecible y cómo un stack de autenticación bien configurado —en particular FreeRADIUS con MFA— reduce drásticamente el radio de explosión de este tipo de incidente.\nQué ocurrió: el ataque explicado # CVE-2026-24858 es un bypass de autenticación en FortiCloud SSO a través del canal SAML. Un atacante con acceso de red al portal de gestión puede construir una aserción SAML manipulada que el dispositivo acepta como válida sin verificar la firma correctamente. El resultado es sesión de administrador sin credenciales.\nEl segundo vector, más sutil, explica por qué la campaña tuvo tanta escala de extracción de credenciales. Los archivos de configuración de FortiGate incluyen un campo old-password que conserva el hash de la contraseña anterior durante el proceso de migración entre versiones. Los dispositivos actualizados a FortiOS 7.2.11+, 7.4.8+ o 7.6.1+ —que cambiaron de SHA-256 a PBKDF2— guardaban el hash SHA-256 en ese campo hasta el siguiente cambio de contraseña manual. Un atacante que extrae el backup de configuración obtiene un hash SHA-256 sin sal, craqueable offline con hardware moderno en minutos para contraseñas débiles o comunes.\n# Extracto típico de config comprometida (estructura anonimizada) config system admin edit \u0026#34;admin\u0026#34; set password ENC \u0026lt;hash-PBKDF2\u0026gt; set old-password \u0026lt;SHA256-sin-sal\u0026gt; ← vector de craqueo offline next end La cadena de ataque completa:\nExplotación de CVE-2026-24858 → acceso administrador vía SAML bypass Extracción del backup de configuración Craqueo offline del hash SHA-256 del campo old-password Reutilización de credenciales: VPN SSL, FortiCloud, Active Directory si el administrador reutilizaba contraseña Escala real: entre el 19 de mayo y el 7 de junio, se registraron ataques contra más de 320.000 dispositivos FortiGate expuestos a internet. El 23% de ellos —75.000— quedaron comprometidos.\nDónde falla el modelo \u0026ldquo;VPN como perímetro\u0026rdquo; # FortiBleed no es excepcional. Es la demostración más reciente de un patrón conocido: los dispositivos de perímetro concentran privilegio de acceso y son objetivos de alto valor. Un dispositivo FortiGate comprometido equivale a llaves de la red entera.\nEl problema estructural es la autenticación de factor único en la VPN. Si el acceso VPN SSL solo requiere usuario y contraseña, comprometer esa contraseña (ya sea por craqueo de hash, phishing o credential stuffing) es suficiente para acceder a la red interna. La VPN se convierte en una puerta con una sola llave.\nMFA en el acceso VPN convierte este ataque en un proceso de dos pasos que requiere comprometer simultáneamente la contraseña y el factor temporal. Para la mayoría de los atacantes, el segundo factor rompe la cadena de ataque aunque las credenciales estén expuestas.\nFreeRADIUS + TOTP como backend MFA para FortiGate # La solución no requiere licencias adicionales de Fortinet. FortiGate soporta autenticación RADIUS nativa y puede delegar el factor adicional a un servidor FreeRADIUS que combine pam_winbind (credenciales AD) con pam_google_authenticator (TOTP de 6 dígitos).\nLa arquitectura resultante:\nUsuario → FortiGate SSL-VPN ↓ RADIUS (PAP) FreeRADIUS 3.x ↓ PAM pam_google_authenticator ← verifica TOTP pam_winbind ← verifica contraseña AD ↓ Active Directory El usuario introduce ContraseñaAD123456 —contraseña seguida del OTP de 6 dígitos— como un string único. FreeRADIUS separa los factores y valida cada uno independientemente. Si el hash SHA-256 de la contraseña AD queda expuesto en un ataque similar a FortiBleed, el atacante aún necesita el código TOTP rotativo para acceder a la VPN.\nConfiguración PAM mínima funcional # # /etc/pam.d/radiusd auth required pam_google_authenticator.so \\ forward_pass \\ secret=/etc/google-authenticator/${USER} \\ user=freerad auth required pam_winbind.so use_first_pass account required pam_winbind.so forward_pass pasa la contraseña AD (sin el OTP ya consumido) al módulo siguiente. use_first_pass evita pedir la contraseña de nuevo. user=freerad es crítico: sin él, pam_google_authenticator intentará hacer setuid y el proceso fallará con el usuario freerad que corre FreeRADIUS.\nActivar Auth-Type PAP en el virtual server # # /etc/freeradius/3.0/sites-enabled/default authorize { preprocess update control { \u0026amp;Auth-Type := PAP } pam } authenticate { Auth-Type PAP { pam } } Sin el bloque update control, FreeRADIUS no sabrá que debe delegar en PAM y rechazará todas las peticiones con No Auth-Type found: rejecting via Post-Auth-Type = Reject.\nLos errores que aparecen durante la implementación # Configurar este stack genera errores específicos que aparecen sistemáticamente. Los siete más frecuentes —con causa exacta y solución— están documentados en detalle en el artículo Errores frecuentes en FreeRADIUS 3.x con PAM: guía de diagnóstico.\nResumen de los puntos más críticos relacionados con la seguridad:\nPermisos del directorio TOTP: si /etc/google-authenticator/ pertenece a root, el proceso freerad no puede crear el fichero temporal que previene replay attacks. El síntoma es Failed to create tempfile: Permission denied. La solución es chown freerad:freerad /etc/google-authenticator/.\nPermisos de los ficheros de secreto: pam_google_authenticator rechaza ficheros con permisos 0644 por razones de seguridad. La autenticación falla silenciosamente desde la perspectiva de FreeRADIUS. Solo visible con freeradius -X. Solución: chmod 600 /etc/google-authenticator/*.\nContraseñas en logs: el módulo auth_log registra atributos sensibles independientemente de auth_badpass = no. Si está habilitado en la sección authorize, las contraseñas aparecerán en los logs aunque la configuración diga lo contrario.\nMedidas de remediación inmediata ante FortiBleed # Si tienes dispositivos FortiGate en producción, las acciones prioritarias son:\n1. Actualizar FortiOS\nFortiOS 7.2.x → 7.2.11 o superior FortiOS 7.4.x → 7.4.8 o superior FortiOS 7.6.x → 7.6.1 o superior 2. Rotar credenciales de administrador después de actualizar\nEl campo old-password se limpia en el siguiente cambio de contraseña. Sin este paso, el hash SHA-256 vulnerable permanece en el backup de configuración incluso después del parche.\n3. Habilitar protección contra hashes débiles\nconfig system global set login-lockout-upon-weaker-encryption enable end 4. Verificar si el dispositivo fue comprometido\n# Buscar accesos SAML anómalos en los logs grep \u0026#34;SAML\u0026#34; /var/log/fortios-forticloud.log | grep -E \u0026#34;login|session\u0026#34; # Verificar cambios de configuración recientes get system status | grep \u0026#34;Last configuration change\u0026#34; 5. Implementar MFA en la VPN SSL\nEste es el cambio estructural que convierte futuras vulnerabilidades de contraseña en ataques incompletos. Con FreeRADIUS + TOTP, un atacante con la contraseña AD no puede acceder a la VPN sin el código temporal del segundo factor.\nEl patrón de fondo # FortiBleed sigue el mismo patrón que CVE-2024-21762 (Fortinet SSL-VPN path traversal), Citrix Bleed (CVE-2023-4966) y el compromiso masivo de Pulse Secure de 2021: los dispositivos de perímetro concentran acceso y son objetivos prioritarios precisamente por eso.\nLa respuesta técnica es doble: mantener el firmware actualizado (reducir la ventana de exposición de vulnerabilidades conocidas) e implementar defensa en profundidad en la autenticación (garantizar que comprometer una capa no equivale a comprometer el acceso completo).\nFreeRADIUS con MFA es una capa de defensa que no depende de que el fabricante del perímetro publique el parche a tiempo. Existe el stack, está documentado y es gratuito.\nPara la implementación completa del stack FreeRADIUS + PAM + pam_google_authenticator + pam_winbind, consulta la serie de artículos sobre infraestructura MFA empresarial en este blog.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/fortibleed-fortigate-freeradius-mfa-hardening/","section":"Posts","summary":"El 7 de junio de 2026 Fortinet confirmó lo que los investigadores de seguridad llevaban semanas documentando: una campaña de compromiso masivo contra dispositivos FortiGate que dejó 75.000 equipos afectados en 194 países. El ataque combinó una vulnerabilidad de autenticación SAML (CVE-2026-24858, CVSS 9.8) con un fallo en cómo FortiOS almacenaba las contraseñas durante el proceso de actualización. El resultado: 1.160 millones de intentos de autenticación en 29 días, credenciales de administrador en manos de actores de amenaza y acceso directo a redes corporativas a través de las mismas VPN diseñadas para protegerlas.\n","title":"FortiBleed 2026: cuando la VPN se convierte en la puerta de entrada","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/fortigate/","section":"Tags","summary":"","title":"Fortigate","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/fortinet/","section":"Tags","summary":"","title":"Fortinet","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/freeradius/","section":"Tags","summary":"","title":"Freeradius","type":"tags"},{"content":" El problema # Un FortiGate configurado con SSL-VPN autenticando contra Active Directory solo pide usuario y contraseña. Si esas credenciales se filtran —un phishing, un password reutilizado, un volcado de NTDS— cualquiera entra a la red corporativa desde internet. El segundo factor elimina ese vector: aunque el atacante tenga la contraseña, sin el código TOTP del momento no pasa.\nLa solución que describo aquí conecta tres piezas:\nFortiGate: termina la VPN y delega la autenticación a RADIUS FreeRADIUS: recibe la petición y la procesa vía PAM PAM: encadena Google Authenticator (TOTP) + Winbind (AD) El usuario introduce ContraseñaAD + 123456 como un solo string. FreeRADIUS separa los factores internamente.\nArquitectura del stack # [Usuario VPN] | | ContraseñaAD123456 (PAP sobre TLS) v [FortiGate SSL-VPN] | | RADIUS Access-Request (PAP) v [FreeRADIUS] | | /etc/pam.d/radiusd v [PAM] ├── pam_google_authenticator ← valida últimos 6 dígitos (OTP) └── pam_winbind ← valida el resto contra AD El flujo PAP es obligatorio aquí: RADIUS necesita el password en claro para que PAM pueda separarlo y validar cada factor. CHAP o MS-CHAPv2 no funcionan con esta arquitectura.\nInstalación # En Ubuntu 22.04 / Debian 12:\napt update apt install freeradius freeradius-config \\ winbind samba libnss-winbind \\ libpam-winbind libpam-google-authenticator Verificar que FreeRADIUS arranca antes de tocar configuración:\nsystemctl stop freeradius freeradius -X # modo debug, ver output limpio Unir el servidor Linux a Active Directory # Editar /etc/samba/smb.conf. La sección [global] debe quedar así:\n[global] workgroup = DOMINIO realm = DOMINIO.LOCAL security = ads encrypt passwords = yes winbind use default domain = yes winbind enum users = yes winbind enum groups = yes winbind offline logon = no winbind separator = + idmap config * : backend = tdb idmap config * : range = 3000-7999 idmap config DOMINIO : backend = rid idmap config DOMINIO : range = 10000-99999 El bloque idmap config DOMINIO es el que más problemas causa cuando falta (ver sección de errores). Sin él, getpwnam() no resuelve los usuarios del dominio y FreeRADIUS falla con \u0026ldquo;user not found\u0026rdquo; aunque el usuario exista en AD.\nUnirse al dominio:\nnet ads join -U Administrador systemctl enable --now winbind Verificar que los usuarios del dominio son visibles:\nwbinfo -u | head getent passwd DOMINIO\\\\usuario.prueba Si getent devuelve la línea del usuario, la integración AD funciona.\nConfiguración de PAM # Crear o editar /etc/pam.d/radiusd:\nauth required pam_google_authenticator.so \\ forward_pass \\ secret=/etc/google-authenticator/${USER} \\ user=freerad auth required pam_winbind.so use_first_pass account required pam_winbind.so Tres directivas clave:\nforward_pass en pam_google_authenticator: el módulo extrae los últimos 6 caracteres del password recibido, los valida como TOTP, y pasa el resto hacia adelante en la pila PAM. Sin esta directiva, el módulo consumiría el password entero y pam_winbind no tendría nada que validar.\nuse_first_pass en pam_winbind: fuerza al módulo a usar el password que quedó en la pila (el que pasó forward_pass) en lugar de pedirlo de nuevo. Si se omite, intenta leer desde stdin y cuelga o falla.\nuser=freerad: pam_google_authenticator lee el fichero de secreto del usuario que hace la petición. El problema es que por defecto intenta leerlo como ese usuario de sistema, y el usuario del dominio no tiene permisos en /etc/google-authenticator/. Fijando user=freerad, el módulo siempre lee como el usuario freerad (proceso de FreeRADIUS), que sí tiene acceso.\nFicheros TOTP por usuario # Crear el directorio de secretos:\nmkdir -p /etc/google-authenticator chown freerad:freerad /etc/google-authenticator chmod 700 /etc/google-authenticator Para cada usuario del dominio, generar el secreto y dejar el fichero donde PAM lo buscará:\n# Ejecutar como el propio usuario, o con google-authenticator y mover el fichero su -s /bin/bash -c \u0026#34;google-authenticator -t -d -f -r 3 -R 30 -w 3\u0026#34; freerad # O generarlo manualmente y colocarlo: mv ~/.google_authenticator /etc/google-authenticator/usuario.dominio chown freerad:freerad /etc/google-authenticator/usuario.dominio chmod 600 /etc/google-authenticator/usuario.dominio Los permisos 0600 son obligatorios. pam_google_authenticator rechaza ficheros con permisos más permisivos como medida de seguridad.\nConfiguración de FreeRADIUS # Declarar el cliente FortiGate # En /etc/freeradius/3.0/clients.conf, añadir:\nclient fortigate-vpn { ipaddr = IP-FORTIGATE secret = secreto-compartido-seguro shortname = fortigate nastype = other } Activar autenticación PAP en el virtual server # El virtual server por defecto está en /etc/freeradius/3.0/sites-enabled/default. En la sección authorize, añadir antes del bloque pap:\nauthorize { ... update control { \u0026amp;Auth-Type := PAP } pap ... } Esto fuerza a FreeRADIUS a usar PAM/PAP para todos los usuarios. Si hay otros métodos activos (LDAP, SQL), asegurarse de que este bloque tiene prioridad o va en un virtual server dedicado.\nActivar el módulo PAM # En /etc/freeradius/3.0/mods-enabled/, comprobar que existe el enlace simbólico pam:\nls -la /etc/freeradius/3.0/mods-enabled/pam # Si no existe: ln -s /etc/freeradius/3.0/mods-available/pam \\ /etc/freeradius/3.0/mods-enabled/pam En la sección authenticate del virtual server:\nauthenticate { Auth-Type PAP { pam } ... } Configuración del FortiGate # En la consola FortiGate:\nServidor RADIUS: User \u0026amp; Authentication → RADIUS Servers → Create New\nIP/Name: IP-SERVIDOR-RADIUS Secret: el mismo secreto-compartido-seguro Authentication method: PAP Grupo de usuarios: crear un grupo que use el servidor RADIUS como fuente.\nSSL-VPN: en la política SSL-VPN, asociar el grupo RADIUS.\nPAP debe estar explícito en FortiGate. Si queda en \u0026ldquo;auto\u0026rdquo;, FortiGate puede intentar MS-CHAP y la autenticación falla silenciosamente.\nPruebas # Antes de tocar el FortiGate, probar localmente con radtest:\n# Iniciar FreeRADIUS en modo debug systemctl stop freeradius freeradius -X \u0026amp; # Probar: password = ContraseñaAD + OTP del momento radtest nombre.usuario \u0026#34;MiPassword123456\u0026#34; 127.0.0.1 0 secreto-local # Respuesta esperada: # Received Access-Accept Id 0 from 127.0.0.1:1812 Si aparece Access-Reject, el modo debug de FreeRADIUS muestra exactamente en qué paso PAM falló. Leer el output completo antes de buscar en otra parte.\nPara probar winbind por separado:\nwbinfo -a \u0026#39;DOMINIO\\usuario.prueba%ContraseñaAD\u0026#39; # Respuesta esperada: plaintext password authentication succeeded Errores frecuentes # Failed to change user id to \u0026quot;nombre.usuario\u0026quot; # FreeRADIUS intenta ejecutar pam_google_authenticator como el usuario del dominio, que no existe como usuario Unix local.\nSolución: añadir user=freerad en la línea de pam_google_authenticator en /etc/pam.d/radiusd.\nuser(\u0026quot;nombre.usuario\u0026quot;) not found # Winbind no resuelve el usuario del dominio. Casi siempre falta la configuración de idmap para el dominio específico en smb.conf.\nSolución: añadir en [global]:\nidmap config DOMINIO : backend = rid idmap config DOMINIO : range = 10000-99999 Reiniciar winbind después: systemctl restart winbind. Confirmar con getent passwd DOMINIO\\\\usuario.\nSecret file permissions (0644) are more permissive than 0600 # El fichero de secreto TOTP tiene permisos demasiado abiertos.\nSolución:\nchmod 600 /etc/google-authenticator/nombre.usuario Failed to create tempfile: Permission denied # pam_google_authenticator intenta escribir un fichero temporal en el directorio de secretos (para el rate limiting y los códigos de un solo uso) y no tiene permiso.\nSolución: el directorio /etc/google-authenticator/ debe ser propiedad de freerad:\nchown freerad:freerad /etc/google-authenticator chmod 700 /etc/google-authenticator Auth-Type already set o autenticación en bucle # Hay un módulo LDAP o SQL activo que también intenta establecer Auth-Type. El bloque update control { \u0026amp;Auth-Type := PAP } usa := (asignación forzada) precisamente para sobreescribir lo que otros módulos hayan puesto. Verificar que no hay otro bloque estableciendo Auth-Type después.\nConclusión # El stack FreeRADIUS + PAM + Google Authenticator + Winbind cubre el requisito de MFA en VPN sin necesidad de licencias adicionales ni appliances dedicados. El coste operativo real está en la gestión de los ficheros TOTP por usuario: cada incorporación requiere generar el secreto, compartir el QR con el usuario y asegurarse de que el fichero tiene los permisos correctos.\nPara entornos con más de 20-30 usuarios, tiene sentido automatizar ese proceso de onboarding con un script que genere el fichero, lo ubique en /etc/google-authenticator/ y envíe el QR por correo cifrado. La base técnica que describe este artículo es la misma independientemente de la escala.\nEl punto más frágil de la arquitectura es el fichero de secreto en disco: si alguien con acceso root al servidor RADIUS extrae esos ficheros, puede clonar los tokens TOTP. Limitar el acceso SSH al servidor, activar auditoría y rotar los secretos periódicamente son las medidas de higiene mínimas para mantener el segundo factor real.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/freeradius-totp-active-directory-mfa-vpn-empresarial/","section":"Posts","summary":"El problema # Un FortiGate configurado con SSL-VPN autenticando contra Active Directory solo pide usuario y contraseña. Si esas credenciales se filtran —un phishing, un password reutilizado, un volcado de NTDS— cualquiera entra a la red corporativa desde internet. El segundo factor elimina ese vector: aunque el atacante tenga la contraseña, sin el código TOTP del momento no pasa.\n","title":"FreeRADIUS + TOTP + Active Directory: doble factor para VPN empresarial desde cero","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/hardening/","section":"Tags","summary":"","title":"Hardening","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/mfa/","section":"Tags","summary":"","title":"Mfa","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/operaciones/","section":"Categories","summary":"","title":"Operaciones","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/operaciones/","section":"Tags","summary":"","title":"Operaciones","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/pam/","section":"Tags","summary":"","title":"Pam","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/peap/","section":"Tags","summary":"","title":"Peap","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/pki/","section":"Tags","summary":"","title":"Pki","type":"tags"},{"content":"Configurar TOTP (Time-based One-Time Password) en un entorno con varios usuarios tiene un problema de distribución: el secret TOTP tiene que llegar al dispositivo del usuario de forma segura. La solución habitual —que el administrador escanee el QR en el teléfono del usuario— no escala, no es cómoda y rompe el modelo de privacidad. Este artículo documenta un microservicio Python que resuelve el problema generando URLs de un solo uso con TTL que el usuario consume desde su propio dispositivo.\nEl problema de distribución # RFC 6238 define TOTP como un algoritmo que comparte un secret entre el servidor y el cliente. El servidor lo almacena en una base de datos o en /etc/users.totp. El cliente lo introduce en Google Authenticator, Aegis o cualquier app compatible. El vector de ataque más común no está en el algoritmo, sino en el momento de la entrega del secret.\nLas alternativas habituales tienen problemas obvios:\nMostrar el secret en texto plano durante el registro: el secret queda en logs del navegador, historial, pantallas observadas. Enviar el QR como imagen adjunta en el email: la imagen puede quedar en servidores de correo intermedios, en caché de clientes de correo, en previews de notificaciones. Que el administrador configure el dispositivo: no escala, rompe la cadena de custodia, y en entornos con rotación de personal es un problema de auditoría. La solución: generar un enlace de un solo uso con TTL de 48 horas que apunte a una página HTML servida localmente. El usuario hace clic, ve el QR, escanea, y el enlace queda invalidado. Si el enlace caduca o ya fue consumido, muestra un error.\nDiseño del sistema # El flujo tiene tres componentes:\nGenerador de tokens: crea un token aleatorio de 32 bytes codificado como URL-safe base64, lo persiste en un JSON con metadatos y TTL. Servidor HTTP mínimo (Flask): recibe el token en la URL, valida TTL y estado used, genera el QR en memoria, sirve la página HTML con el QR embebido como data URI. Notificador SMTP: envía el enlace al email del usuario con autenticación manual para manejar contraseñas con caracteres no-ASCII. Los tokens se almacenan en un directorio tokens/ como ficheros JSON individuales. Sin base de datos, sin dependencias externas más allá de Flask y qrencode.\nEstructura del token # import secrets, json, time from pathlib import Path def generate_token(username): token = secrets.token_urlsafe(32) data = { \u0026#34;user\u0026#34;: username, \u0026#34;created\u0026#34;: time.time(), \u0026#34;ttl\u0026#34;: 172800, # 48h en segundos \u0026#34;used\u0026#34;: False } Path(f\u0026#34;tokens/{token}.json\u0026#34;).write_text(json.dumps(data)) return token secrets.token_urlsafe(32) genera 32 bytes de entropía criptográfica, codificados en base64 URL-safe: 43 caracteres sin padding, sin / ni +, seguros para usarlos directamente en URLs sin encoding adicional. La entropía efectiva es 256 bits —suficiente para que la probabilidad de colisión sea irrelevante en cualquier escenario real.\nValidación del token en el servidor # from flask import Flask, abort, render_template_string import json, time from pathlib import Path app = Flask(__name__) @app.route(\u0026#34;/setup/\u0026lt;token\u0026gt;\u0026#34;) def setup(token): token_file = Path(f\u0026#34;tokens/{token}.json\u0026#34;) if not token_file.exists(): abort(404) data = json.loads(token_file.read_text()) if data[\u0026#34;used\u0026#34;]: abort(410) # Gone — ya fue consumido if time.time() - data[\u0026#34;created\u0026#34;] \u0026gt; data[\u0026#34;ttl\u0026#34;]: abort(410) # Gone — expirado # Marcar como usado ANTES de servir la respuesta data[\u0026#34;used\u0026#34;] = True token_file.write_text(json.dumps(data)) username = data[\u0026#34;user\u0026#34;] secret = get_totp_secret(username) # Recuperar de almacén seguro return render_totp_page(username, secret) El orden importa: se marca used: true antes de servir la respuesta, no después. Si el servidor falla entre el marcado y el envío, el usuario tendrá que solicitar un nuevo enlace, pero no hay ventana donde un atacante pueda consumir el enlace en paralelo.\nGeneración del QR en servidor # El QR no se genera en el cliente. Se genera en el servidor con qrencode, se convierte a base64 y se embebe directamente en el HTML como data URI. Esto evita que la imagen salga del servidor hacia un servicio externo de generación de QR.\nURI otpauth # El formato estándar que entienden todas las apps TOTP es:\notpauth://totp/ISSUER:usuario?secret=XXX\u0026amp;issuer=ISSUER\u0026amp;algorithm=SHA1\u0026amp;digits=6\u0026amp;period=30 ISSUER aparece dos veces: como prefijo del nombre de cuenta y como parámetro issuer. Algunas apps solo leen uno de los dos; incluir ambos garantiza compatibilidad. secret en base32 (RFC 4648), sin padding. algorithm=SHA1 es el único que soportan la mayoría de apps TOTP en la práctica; SHA256 y SHA512 están en el RFC pero el soporte es inconsistente. digits=6 y period=30 son los valores por defecto de Google Authenticator. Generación del PNG # import subprocess, base64 def generate_qr_base64(otpauth_uri: str) -\u0026gt; str: result = subprocess.run( [\u0026#34;qrencode\u0026#34;, \u0026#34;-o\u0026#34;, \u0026#34;-\u0026#34;, \u0026#34;-t\u0026#34;, \u0026#34;PNG\u0026#34;, \u0026#34;-s\u0026#34;, \u0026#34;6\u0026#34;, otpauth_uri], capture_output=True, check=True ) return base64.b64encode(result.stdout).decode() def render_totp_page(username: str, secret: str) -\u0026gt; str: issuer = \u0026#34;MiServicio\u0026#34; uri = f\u0026#34;otpauth://totp/{issuer}:{username}?secret={secret}\u0026amp;issuer={issuer}\u0026amp;algorithm=SHA1\u0026amp;digits=6\u0026amp;period=30\u0026#34; qr_b64 = generate_qr_base64(uri) # qr_b64 se embebe en el HTML como data:image/png;base64,... return HTML_TEMPLATE.format(username=username, secret=secret, qr_b64=qr_b64) El tamaño -s 6 genera celdas de 6 píxeles: un QR legible en pantalla sin ser exageradamente grande. qrencode está disponible en los repositorios de cualquier distribución Debian/Ubuntu/RHEL.\nEnvío del enlace por SMTP: el problema con contraseñas no-ASCII # smtplib es la librería estándar de Python para SMTP. Su método login() falla con UnicodeEncodeError si la contraseña contiene caracteres fuera de ASCII —como la letra Ñ, tildes, o cualquier carácter con código \u0026gt; 127. Esto ocurre porque smtplib intenta codificar la contraseña en latin-1 para ciertos servidores, y si el carácter no existe en ese charset, lanza una excepción que no documenta claramente el motivo.\nLa solución es autenticar manualmente con AUTH PLAIN construyendo el payload en base64 desde bytes UTF-8:\nimport smtplib, base64, ssl def send_setup_email(to_email: str, username: str, setup_url: str): smtp_host = \u0026#34;smtp.ejemplo.com\u0026#34; smtp_port = 465 smtp_user = \u0026#34;notificaciones@ejemplo.com\u0026#34; smtp_pass = \u0026#34;contraseña_con_Ñ\u0026#34; # Leída desde variable de entorno subject = f\u0026#34;Configura tu autenticador para {username}\u0026#34; body = f\u0026#34;\u0026#34;\u0026#34;Hola {username}, Accede al siguiente enlace desde tu dispositivo móvil para configurar Google Authenticator o Aegis. El enlace es válido durante 48 horas y solo puede usarse una vez: {setup_url} Si no solicitaste este enlace, ignora este email. \u0026#34;\u0026#34;\u0026#34; msg = f\u0026#34;From: {smtp_user}\\r\\nTo: {to_email}\\r\\nSubject: {subject}\\r\\n\\r\\n{body}\u0026#34; context = ssl.create_default_context() with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as smtp: smtp.ehlo() # AUTH PLAIN manual: evita UnicodeEncodeError con contraseñas no-ASCII auth_payload = base64.b64encode( b\u0026#34;\\x00\u0026#34; + smtp_user.encode(\u0026#34;utf-8\u0026#34;) + b\u0026#34;\\x00\u0026#34; + smtp_pass.encode(\u0026#34;utf-8\u0026#34;) ).decode(\u0026#34;ascii\u0026#34;) smtp.docmd(\u0026#34;AUTH PLAIN\u0026#34;, auth_payload) smtp.sendmail(smtp_user, [to_email], msg.encode(\u0026#34;utf-8\u0026#34;)) El protocolo AUTH PLAIN define el payload como \\x00authzid\\x00authcid\\x00password o en su forma más común sin authzid: \\x00usuario\\x00contraseña. Los tres campos se concatenan en bytes UTF-8, se codifican en base64 y se envían como argumento del comando AUTH PLAIN. El servidor lo decodifica en UTF-8 en su extremo. smtplib.login() hace exactamente esto internamente, pero con una codificación que falla antes de llegar al servidor cuando hay caracteres no-ASCII.\nInterfaz HTML embebida # La página que ve el usuario no tiene dependencias externas. Sin CDN, sin JavaScript de terceros, sin tracking. Todo en una sola respuesta HTTP:\n\u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;es\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Configurar autenticador — {username}\u0026lt;/title\u0026gt; \u0026lt;style\u0026gt; body {{ font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 1rem; }} .qr {{ text-align: center; margin: 2rem 0; }} .secret {{ font-family: monospace; background: #f4f4f4; padding: .5rem 1rem; border-radius: 4px; word-break: break-all; letter-spacing: .1em; }} .warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: .75rem 1rem; }} \u0026lt;/style\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Configurar Google Authenticator\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;Escanea el código QR con tu app de autenticación (Google Authenticator, Aegis, etc.).\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;qr\u0026#34;\u0026gt; \u0026lt;img src=\u0026#34;data:image/png;base64,{qr_b64}\u0026#34; alt=\u0026#34;QR TOTP\u0026#34; width=\u0026#34;250\u0026#34; height=\u0026#34;250\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;p\u0026gt;Si no puedes escanear el QR, introduce el secret manualmente:\u0026lt;/p\u0026gt; \u0026lt;p class=\u0026#34;secret\u0026#34;\u0026gt;{secret}\u0026lt;/p\u0026gt; \u0026lt;div class=\u0026#34;warning\u0026#34;\u0026gt; \u0026lt;strong\u0026gt;Este enlace ya no es válido.\u0026lt;/strong\u0026gt; Si necesitas reconfigurarlo, contacta con el administrador para obtener un nuevo enlace. \u0026lt;/div\u0026gt; \u0026lt;h2\u0026gt;Códigos de emergencia\u0026lt;/h2\u0026gt; \u0026lt;p\u0026gt;Guarda estos códigos en un lugar seguro. Cada uno es de un solo uso:\u0026lt;/p\u0026gt; \u0026lt;ul\u0026gt; {emergency_codes} \u0026lt;/ul\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Los códigos de emergencia se generan en el mismo paso (secrets.token_hex(4) repetido 8 veces), se almacenan hasheados junto al registro TOTP del usuario y se muestran una única vez en esta página. Después no son recuperables.\nServicio systemd y comandos de gestión # # /etc/systemd/system/totp-provisioning.service [Unit] Description=TOTP Provisioning Service After=network.target [Service] Type=simple User=totp-svc WorkingDirectory=${BASE_DIR}/totp-provisioning EnvironmentFile=${BASE_DIR}/totp-provisioning/.env ExecStart=/usr/bin/python3 server.py Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target Los comandos de administración son dos subcomandos del mismo script:\n# Crear token y enviar email al usuario python3 manage.py send usuario@dominio.com usuario # Generar token sin enviar email (para depuración o entrega alternativa) python3 manage.py token usuario send llama a generate_token(), construye la URL completa y llama a send_setup_email(). token solo imprime la URL en stdout para que el administrador la entregue por otro canal si lo prefiere.\nConclusión # Este patrón resuelve el problema de distribución de secrets TOTP sin comprometer la seguridad ni requerir intervención manual en el dispositivo del usuario. Los puntos clave del diseño:\nEntropía suficiente: 256 bits hacen que la enumeración de tokens sea computacionalmente inviable. Un solo uso: el token queda invalidado tras el primer acceso, independientemente de si el usuario completa la configuración. TTL corto: 48 horas reduce la ventana de exposición si el email es interceptado. Sin dependencias externas en el cliente: la página HTML es autocontenida; el QR se genera y embebe en servidor. Autenticación SMTP robusta: el workaround con AUTH PLAIN manual elimina una clase de errores silenciosos que aparecen solo en producción cuando las contraseñas contienen caracteres no-ASCII. El servicio es deliberadamente mínimo —menos de 300 líneas incluyendo la plantilla HTML— porque en seguridad, el código que no existe no tiene vulnerabilidades.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/provisioning-totp-urls-un-solo-uso-qr-codes-email/","section":"Posts","summary":"Configurar TOTP (Time-based One-Time Password) en un entorno con varios usuarios tiene un problema de distribución: el secret TOTP tiene que llegar al dispositivo del usuario de forma segura. La solución habitual —que el administrador escanee el QR en el teléfono del usuario— no escala, no es cómoda y rompe el modelo de privacidad. Este artículo documenta un microservicio Python que resuelve el problema generando URLs de un solo uso con TTL que el usuario consume desde su propio dispositivo.\n","title":"Provisioning TOTP con URLs de un solo uso: entrega segura de QR codes por email","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/python/","section":"Categories","summary":"","title":"Python","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/qr-code/","section":"Tags","summary":"","title":"Qr-Code","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/radius/","section":"Tags","summary":"","title":"Radius","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/rotaci%C3%B3n/","section":"Tags","summary":"","title":"Rotación","type":"tags"},{"content":" Por qué rotar el shared secret RADIUS # El shared secret RADIUS no es una contraseña de usuario: es la clave que cifra el campo User-Password en cada paquete RADIUS entre el NAS (FortiGate, AP, switch) y el servidor FreeRADIUS. El protocolo PAP transporta la contraseña del usuario XOR-cifrada con un hash MD5 derivado del shared secret y un vector aleatorio del paquete.\nEl problema es que ese cifrado es débil por diseño: si alguien captura tráfico RADIUS durante meses y después consigue el shared secret, puede desencriptar retroactivamente todas esas contraseñas. No hay perfect forward secrecy en RADIUS clásico.\nA esto se suma que muchas instalaciones nunca cambiaron el secret del ejemplo de configuración. Si en tu clients.conf ves secret = testing123, no estás solo. Y si llevas años con el mismo valor, cualquier empleado que haya tenido acceso a la configuración lo conoce indefinidamente.\nDesde el punto de vista regulatorio, tanto ISO 27001 como el ENS (Esquema Nacional de Seguridad) exigen rotación periódica de credenciales de infraestructura. El shared secret RADIUS entra en esa categoría.\nEl riesgo central: la coordinación atómica # Aquí está el problema operativo real. FortiGate y FreeRADIUS deben tener el mismo secret en el mismo instante. No hay handshake de negociación, no hay período de gracia, no hay solapamiento posible: un paquete con el secret incorrecto simplemente produce un Access-Reject y el usuario no se autentica.\nSi cambias FreeRADIUS primero y tardas 30 segundos en actualizar el FortiGate, durante esos 30 segundos nadie puede conectarse por VPN ni autenticarse en WiFi corporativo. Si lo haces al revés, igual.\nEsta naturaleza atómica es lo que hace que muchos administradores pospongan la rotación indefinidamente. El objetivo de este proceso es reducir esa ventana de impacto a unos pocos segundos y tener un plan de reversión claro si algo falla.\nLimitaciones antes de empezar # Antes de generar el nuevo secret, hay restricciones del hardware que debes conocer:\nLongitud máxima: algunos modelos FortiGate más antiguos aceptan secrets de hasta 32 caracteres. Otros permiten más, pero la interoperabilidad entre NAS y servidor puede verse afectada con secrets muy largos. El rango seguro es 20-32 caracteres.\nCaracteres permitidos: ciertos NAS tienen problemas con caracteres especiales. Los caracteres @, # y ! son los más conflictivos, especialmente en firmwares antiguos o implementaciones que no escapan correctamente el valor antes de pasarlo al stack RADIUS. Usar únicamente caracteres alfanuméricos elimina esa clase de problemas.\nVerificar antes: si tienes dudas sobre tu modelo específico de FortiGate, genera un secret de 28 caracteres alfanuméricos y pruébalo en un entorno de laboratorio antes de tocarlo en producción.\nGeneración del nuevo secret # Un secret generado con entropía criptográfica real, sin caracteres problemáticos y de longitud adecuada:\nopenssl rand -base64 24 | tr -d \u0026#39;=+/\u0026#39; | head -c 28 Esto genera 24 bytes aleatorios, los codifica en base64, elimina los caracteres =, + y /, y toma los primeros 28 caracteres. El resultado es alfanumérico puro con alta entropía.\nGuarda este valor en tu gestor de contraseñas corporativo antes de continuar. Lo necesitarás en tres lugares distintos durante el proceso.\nEl proceso paso a paso # 1. Elegir la ventana de mantenimiento # Trabaja en horario de baja actividad: entre semana a las 2-4h, o fin de semana por la mañana. Notifica con antelación si hay usuarios con VPN activa. El impacto real si todo va bien es de 5-15 segundos; si hay que revertir, puede ser de 2-3 minutos.\n2. Preparar FreeRADIUS (sin aplicar todavía) # Edita /etc/freeradius/3.0/clients.conf con el nuevo secret pero no recargues el servicio aún:\nclient fortigate { ipaddr = IP-FORTIGATE secret = NuevoSecretAqui nastype = other require_message_authenticator = no } Ten el archivo abierto en un editor o el cambio listo con sed preparado. El objetivo es que entre el cambio en FortiGate y el reload de FreeRADIUS pasen los menos segundos posibles.\n3. Cambiar el secret en FortiGate # Desde la CLI de FortiGate:\nconfig user radius edit \u0026#34;servidor-radius\u0026#34; set server IP-RADIUS set secret NuevoSecretAqui next end O desde la interfaz web: User \u0026amp; Authentication → RADIUS Servers → editar el servidor → campo Secret.\nEn el momento en que aplicas este cambio, FortiGate empieza a enviar paquetes con el nuevo secret. FreeRADIUS todavía espera el antiguo: hay impacto desde este instante.\n4. Recargar FreeRADIUS inmediatamente # systemctl reload freeradius reload es suficiente si FreeRADIUS soporta recarga de configuración en caliente (versiones \u0026gt;= 3.0). Si tienes dudas o el reload no funciona en tu versión:\nsystemctl restart freeradius El restart añade 1-2 segundos adicionales de indisponibilidad, pero garantiza que se carga la configuración limpia.\n5. Verificar con radtest # Antes de dar la rotación por finalizada, valida que el nuevo secret funciona de extremo a extremo:\nradtest usuario contraseña+OTP IP-RADIUS 0 NuevoSecretAqui Una respuesta exitosa devuelve Access-Accept. Cualquier otra respuesta indica un problema.\n6. Plan de reversión # Si radtest falla o ves Access-Reject en los logs, revierte en el orden inverso:\nRestaura el secret antiguo en FortiGate (CLI o web) Restaura el clients.conf con el secret antiguo systemctl reload freeradius Confirma con radtest usando el secret antiguo Por eso es fundamental tener el secret anterior a mano durante toda la operación. No lo elimines del gestor de contraseñas hasta que la verificación post-rotación esté completa.\nGestión de múltiples clientes RADIUS # Si tienes varios NAS (múltiples FortiGate, APs con autenticación 802.1X, switches con TACACS+ extendido a RADIUS), no los cambies todos a la vez.\nEl enfoque correcto:\nRotar un cliente cada vez Completar la verificación de ese cliente antes de pasar al siguiente Si los clientes usan secrets diferentes (lo cual es recomendable), documenta qué secret usa cada uno En clients.conf puedes tener entradas separadas por cliente con secrets distintos:\nclient fortigate-sede-madrid { ipaddr = IP-FORTIGATE-MAD secret = SecretMadrid28Chars nastype = other } client ap-controller { ipaddr = IP-WLAN-CONTROLLER secret = SecretAPs28Chars nastype = other } Esto te permite rotar cada cliente de forma independiente y reduce el radio de impacto si algo sale mal.\nAlmacenamiento seguro del secret # El clients.conf contiene secrets en claro, lo que lo convierte en un archivo crítico:\nchown freerad:freerad /etc/freeradius/3.0/clients.conf chmod 640 /etc/freeradius/3.0/clients.conf Estos permisos aseguran que solo el proceso FreeRADIUS y root pueden leerlo. Verifica que tu sistema de backup no está exportando este archivo a un destino sin cifrado.\nPara entornos con mayor madurez de seguridad, las alternativas son:\nHashiCorp Vault: el script de despliegue recupera el secret de Vault en tiempo de ejecución y escribe clients.conf con vault kv get. El archivo nunca se persiste en disco sin cifrado. Bitwarden o KeePass corporativo: para equipos pequeños, guardar el secret en una bóveda compartida con acceso auditado es suficiente y mucho mejor que una hoja de cálculo o un archivo de texto. Lo que no es aceptable: el secret en un documento de Word compartido por email, en un comentario de un ticket de soporte, o en el historial de bash de un administrador.\nVerificación post-rotación # Una vez completada la rotación, el proceso de verificación no termina con radtest. Los pasos completos:\nInmediatos (primeros 2 minutos):\n# Verificar que no hay errores en el log de FreeRADIUS tail -f /var/log/freeradius/radius.log Busca líneas con Access-Accept para autenticaciones reales. Cualquier Access-Reject inesperado puede indicar un cliente que todavía usa el secret antiguo.\nPrueba funcional:\nConectar un usuario de prueba a la VPN y verificar que autentica correctamente Si tienes WiFi 802.1X, conectar un dispositivo de prueba a la red corporativa Verificar que las autenticaciones aparecen en los logs con el timestamp correcto Monitorización los primeros 15 minutos:\n# Si usas fail2ban para proteger RADIUS tail -f /var/log/fail2ban.log Un pico de Access-Reject seguido de bloqueos en fail2ban puede indicar que algún dispositivo (impresora, teléfono corporativo, servicio automatizado) no se actualizó con las nuevas credenciales y está bloqueando IPs legítimas.\nConclusión # La rotación del shared secret RADIUS no es complicada técnicamente, pero requiere coordinación y velocidad en los pasos críticos. El riesgo no está en el proceso en sí, sino en hacerlo de forma improvisada o sin tener claro el plan de reversión.\nCon este procedimiento: ventana de impacto de menos de 15 segundos si todo va bien, reversión en menos de 3 minutos si algo falla, y el proceso documentado para repetirlo cada 6-12 meses como parte del calendario de rotación de credenciales de infraestructura.\nEl secret testing123 que llevas años usando puede esperar hasta esta noche. Pero no más.\n","date":"June 18, 2026","externalUrl":null,"permalink":"/posts/rotacion-shared-secrets-radius-sin-corte-servicio/","section":"Posts","summary":"Por qué rotar el shared secret RADIUS # El shared secret RADIUS no es una contraseña de usuario: es la clave que cifra el campo User-Password en cada paquete RADIUS entre el NAS (FortiGate, AP, switch) y el servidor FreeRADIUS. El protocolo PAP transporta la contraseña del usuario XOR-cifrada con un hash MD5 derivado del shared secret y un vector aleatorio del paquete.\n","title":"Rotación de shared secrets RADIUS: proceso coordinado sin cortar el servicio","type":"posts"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/secrets/","section":"Tags","summary":"","title":"Secrets","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/ssl/","section":"Tags","summary":"","title":"Ssl","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/totp/","section":"Tags","summary":"","title":"Totp","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/ufw/","section":"Tags","summary":"","title":"Ufw","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/wifi/","section":"Tags","summary":"","title":"Wifi","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/wifi/","section":"Categories","summary":"","title":"WiFi","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/winbind/","section":"Tags","summary":"","title":"Winbind","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/categories/windows/","section":"Categories","summary":"","title":"Windows","type":"categories"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/windows-server/","section":"Tags","summary":"","title":"Windows-Server","type":"tags"},{"content":"","date":"June 18, 2026","externalUrl":null,"permalink":"/tags/wpa-enterprise/","section":"Tags","summary":"","title":"Wpa-Enterprise","type":"tags"},{"content":"","date":"June 17, 2026","externalUrl":null,"permalink":"/tags/almacenamiento-remoto/","section":"Tags","summary":"","title":"Almacenamiento-Remoto","type":"tags"},{"content":" El problema # Tengo varios contenedores Docker con datos críticos en volúmenes. Un día se corrompió una base de datos y perdí días de información. Después de eso decidí automatizar los backups incrementales a almacenamiento remoto. Aquí comparto cómo lo hice.\nPreparación # Primero instalé duplicity en el servidor anfitrión:\napt-get install duplicity python3-pip pip3 install boto3 # si usas S3 Necesitaba acceso a almacenamiento remoto. En mi caso usé B2 de Backblaze, que es económico. También puedes usar S3, Google Cloud Storage o incluso un servidor SFTP propio.\nGeneré las credenciales necesarias y las guardé en un archivo seguro:\ncat \u0026gt; /root/.duplicity_env \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; export B2_ACCOUNT_ID=\u0026#34;tu_account_id\u0026#34; export B2_APP_KEY=\u0026#34;tu_app_key\u0026#34; EOF chmod 600 /root/.duplicity_env Estructura de backups # Decidí hacer backup de los volúmenes por separado. Para un contenedor con PostgreSQL, primero detenía el contenedor para garantizar consistencia:\ndocker stop mi_postgres Luego ejecutaba duplicity:\nsource /root/.duplicity_env duplicity \\ --full-if-older-than 30D \\ --include=/var/lib/docker/volumes/postgres_data/_data \\ --exclude=\u0026#39;**\u0026#39; \\ /var/lib/docker/volumes/postgres_data/_data \\ b2://bucket_name/postgres_backup docker start mi_postgres Este comando hace backups completos cada 30 días e incrementales el resto del tiempo. Sin --full-if-older-than, los incrementales se acumulan y la restauración es más lenta.\nAutomatización con cron # Creé un script de backup completo:\ncat \u0026gt; /usr/local/bin/backup-docker-volumes.sh \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; #!/bin/bash source /root/.duplicity_env TIMESTAMP=$(date +%Y%m%d_%H%M%S) LOG_FILE=\u0026#34;/var/log/docker-backups-$TIMESTAMP.log\u0026#34; backup_volume() { local volume_name=$1 local b2_path=$2 local container_name=$3 echo \u0026#34;[$(date)] Iniciando backup de $volume_name\u0026#34; \u0026gt;\u0026gt; $LOG_FILE docker stop $container_name 2\u0026gt;/dev/null sleep 5 duplicity \\ --full-if-older-than 30D \\ --include=/var/lib/docker/volumes/${volume_name}/_data \\ --exclude=\u0026#39;**\u0026#39; \\ /var/lib/docker/volumes/${volume_name}/_data \\ b2://$b2_path \u0026gt;\u0026gt; $LOG_FILE 2\u0026gt;\u0026amp;1 docker start $container_name echo \u0026#34;[$(date)] Backup de $volume_name completado\u0026#34; \u0026gt;\u0026gt; $LOG_FILE } backup_volume \u0026#34;postgres_data\u0026#34; \u0026#34;mi_bucket/postgres\u0026#34; \u0026#34;postgres\u0026#34; backup_volume \u0026#34;appdata\u0026#34; \u0026#34;mi_bucket/app\u0026#34; \u0026#34;webapp\u0026#34; duplicity cleanup --force b2://mi_bucket/postgres 2\u0026gt;/dev/null duplicity cleanup --force b2://mi_bucket/app 2\u0026gt;/dev/null EOF chmod +x /usr/local/bin/backup-docker-volumes.sh Lo agregué a crontab para que corra cada noche:\n0 2 * * * /usr/local/bin/backup-docker-volumes.sh Restauración ante fallos # Cuando un contenedor falla, la restauración es sencilla:\n# Crear directorio temporal mkdir -p /tmp/restore cd /tmp/restore source /root/.duplicity_env # Restaurar desde b2 duplicity restore b2://mi_bucket/postgres . # Los datos están en /tmp/restore ls -la Luego puedo copiar los datos al volumen:\ndocker stop postgres cp -r /tmp/restore/* /var/lib/docker/volumes/postgres_data/_data/ docker start postgres Verificación # Ejecuto verificaciones mensuales:\nduplicity verify b2://mi_bucket/postgres /var/lib/docker/volumes/postgres_data/_data Esto comprueba que los backups sean válidos sin restaurarlos completamente.\nLecciones aprendidas # Los backups sin verificación no sirven. He encontrado problemas en restauraciones de prueba. Detener el contenedor antes de backup es crítico para bases de datos. Los backups incrementales después de 30 días se vuelven difíciles de restaurar. Por eso limito el intervalo de completos. Guarda credenciales con chmod 600 siempre. Esta configuración me ha salvado varias veces. La automatización es la clave: sin cron, nunca hubiera tenido backups consistentes.\n","date":"June 17, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incremental-de-volumenes-docker-a-almacenamiento-remoto-con-duplicity/","section":"Posts","summary":"El problema # Tengo varios contenedores Docker con datos críticos en volúmenes. Un día se corrompió una base de datos y perdí días de información. Después de eso decidí automatizar los backups incrementales a almacenamiento remoto. Aquí comparto cómo lo hice.\nPreparación # Primero instalé duplicity en el servidor anfitrión:\n","title":"Automatizar backups incremental de volúmenes Docker a almacenamiento remoto con duplicity","type":"posts"},{"content":"","date":"June 17, 2026","externalUrl":null,"permalink":"/tags/duplicity/","section":"Tags","summary":"","title":"Duplicity","type":"tags"},{"content":" El problema # Hace tiempo me pasó: un volumen Docker corrupto y sin backup limpio. Desde entonces, automatizo backups incrementales de mis volúmenes críticos. Te muestro cómo lo hago.\nRequisitos # Servidor Docker en Linux (Debian/Ubuntu) Acceso SSH a almacenamiento remoto (NAS, VPS, etc.) rsync instalado Bot de Telegram configurado Permisos de sudo o acceso directo a volúmenes Paso 1: Preparar el script de backup # Creo /opt/docker-backup/backup.sh:\n#!/bin/bash BACKUP_SOURCE=\u0026#34;/var/lib/docker/volumes\u0026#34; BACKUP_DEST=\u0026#34;user@remote-storage:/backups/docker-volumes\u0026#34; LOG_FILE=\u0026#34;/var/log/docker-backup.log\u0026#34; CHECKSUM_FILE=\u0026#34;/var/log/docker-backup.checksum\u0026#34; TELEGRAM_TOKEN=\u0026#34;YOUR_BOT_TOKEN\u0026#34; TELEGRAM_CHAT=\u0026#34;YOUR_CHAT_ID\u0026#34; # Función para enviar alertas por Telegram send_alert() { local message=$1 local status=$2 if [ \u0026#34;$status\u0026#34; = \u0026#34;error\u0026#34; ]; then curl -s -X POST \u0026#34;https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage\u0026#34; \\ -d chat_id=\u0026#34;${TELEGRAM_CHAT}\u0026#34; \\ -d text=\u0026#34;❌ Docker Backup Error: ${message}\u0026#34; else curl -s -X POST \u0026#34;https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage\u0026#34; \\ -d chat_id=\u0026#34;${TELEGRAM_CHAT}\u0026#34; \\ -d text=\u0026#34;✅ Docker Backup: ${message}\u0026#34; fi } # Crear directorio de log si no existe mkdir -p $(dirname \u0026#34;$LOG_FILE\u0026#34;) echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] Iniciando backup incremental\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; # Ejecutar rsync incremental rsync -av --delete \\ --backup-dir=\u0026#34;/backups/docker-volumes/daily-$(date +%Y%m%d)\u0026#34; \\ \u0026#34;$BACKUP_SOURCE/\u0026#34; \u0026#34;$BACKUP_DEST/\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] Backup completado exitosamente\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; BACKUP_STATUS=\u0026#34;exitoso\u0026#34; else echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] ERROR en backup\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; send_alert \u0026#34;Fallo en transferencia rsync\u0026#34; \u0026#34;error\u0026#34; exit 1 fi Paso 2: Validación de integridad # Agrego a backup.sh:\n# Generar checksum local find \u0026#34;$BACKUP_SOURCE\u0026#34; -type f -exec md5sum {} \\; | sort \u0026gt; /tmp/local.checksum # Verificar checksum remoto ssh user@remote-storage \u0026#34;find /backups/docker-volumes -type f -exec md5sum {} \\; | sort \u0026gt; /tmp/remote.checksum\u0026#34; # Comparar rsync --checksum \u0026#34;$BACKUP_SOURCE/\u0026#34; \u0026#34;$BACKUP_DEST/\u0026#34; --dry-run \u0026gt; /tmp/verify.log 2\u0026gt;\u0026amp;1 DIFF_COUNT=$(grep -c \u0026#34;would be transferred\u0026#34; /tmp/verify.log) if [ \u0026#34;$DIFF_COUNT\u0026#34; -gt 0 ]; then send_alert \u0026#34;Advertencia: ${DIFF_COUNT} archivos no sincronizados\u0026#34; \u0026#34;error\u0026#34; else send_alert \u0026#34;Integridad verificada: $(du -sh $BACKUP_SOURCE | cut -f1)\u0026#34; \u0026#34;ok\u0026#34; fi Paso 3: Configurar cron # Edito /etc/cron.d/docker-backup:\n# Backup completo cada domingo a las 2 AM 0 2 * * 0 root /opt/docker-backup/backup.sh # Backup incremental diario a las 3 AM 0 3 * * 1-6 root /opt/docker-backup/backup.sh # Limpieza de backups antiguos (más de 30 días) cada viernes 0 4 * * 5 root find /backups/docker-volumes/daily-* -mtime +30 -delete Paso 4: Configurar Telegram # Creo un bot en BotFather y obtengo el token. Luego:\n# Probar conexión curl -s -X POST \u0026#34;https://api.telegram.org/botTOKEN/sendMessage\u0026#34; \\ -d chat_id=\u0026#34;CHAT_ID\u0026#34; \\ -d text=\u0026#34;Test de conexión\u0026#34; Paso 5: Permisos y prueba # chmod 750 /opt/docker-backup/backup.sh chmod 640 /etc/cron.d/docker-backup # Ejecutar manualmente para verificar /opt/docker-backup/backup.sh En producción # Reviso logs semanalmente:\ntail -f /var/log/docker-backup.log He detectado problemas temprano gracias a las alertas de Telegram. El script se ejecuta sin intervención manual y rsync solo transfiere lo que cambió. Con esto dormía más tranquilo.\n","date":"June 15, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-con-rsync-validacion-y-alertas-por-telegram/","section":"Posts","summary":"El problema # Hace tiempo me pasó: un volumen Docker corrupto y sin backup limpio. Desde entonces, automatizo backups incrementales de mis volúmenes críticos. Te muestro cómo lo hago.\nRequisitos # Servidor Docker en Linux (Debian/Ubuntu) Acceso SSH a almacenamiento remoto (NAS, VPS, etc.) rsync instalado Bot de Telegram configurado Permisos de sudo o acceso directo a volúmenes Paso 1: Preparar el script de backup # Creo /opt/docker-backup/backup.sh:\n","title":"Automatizar backups incrementales de volúmenes Docker con rsync, validación y alertas por Telegram","type":"posts"},{"content":"","date":"June 15, 2026","externalUrl":null,"permalink":"/tags/telegram/","section":"Tags","summary":"","title":"Telegram","type":"tags"},{"content":" Automatizar backups incrementales de volúmenes Docker con rsync y systemd timers # Después de perder datos por un fallo de disco, decidí implementar un sistema robusto de backups para mis volúmenes Docker. La solución que uso hoy es simple, confiable y se ejecuta sin intervención manual.\nEl problema # Tener contenedores con datos persistentes sin respaldo es un riesgo que no me puedo permitir. Los backups deben ser:\nIncrementales (no duplicar datos) Automatizados (sin recordarlos) Verificables (saber si son confiables) Remotos (proteger contra fallos locales) La solución: rsync + systemd timers # Uso rsync porque es eficiente con cambios incrementales y systemd timers porque no necesito cron ni servicios adicionales.\n1. Preparar directorios y credenciales # Primero, identifico mis volúmenes Docker:\ndocker volume ls En mi caso, los volúmenes están en /var/lib/docker/volumes/. Creo un directorio local de staging:\nmkdir -p /mnt/backups/docker-volumes chmod 700 /mnt/backups Para el almacenamiento remoto, configuro SSH sin contraseña. Genero una clave dedicada:\nssh-keygen -t ed25519 -f /root/.ssh/backup_key -N \u0026#34;\u0026#34; -C \u0026#34;docker-backup\u0026#34; La añado al servidor remoto en .ssh/authorized_keys del usuario de backup.\n2. Script de backup con verificación # Creo /usr/local/bin/docker-backup.sh:\n#!/bin/bash set -e BACKUP_DIR=\u0026#34;/mnt/backups/docker-volumes\u0026#34; REMOTE_USER=\u0026#34;backup\u0026#34; REMOTE_HOST=\u0026#34;backup.example.com\u0026#34; REMOTE_PATH=\u0026#34;/backups/docker\u0026#34; SSH_KEY=\u0026#34;/root/.ssh/backup_key\u0026#34; CHECKSUM_FILE=\u0026#34;${BACKUP_DIR}/.checksums\u0026#34; # Función para loguear log() { echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] $1\u0026#34; | tee -a /var/log/docker-backup.log } log \u0026#34;Iniciando backup de volúmenes Docker\u0026#34; # Copiar volúmenes locales docker volume ls -q | while read volume; do SOURCE=\u0026#34;/var/lib/docker/volumes/${volume}/_data\u0026#34; DEST=\u0026#34;${BACKUP_DIR}/${volume}\u0026#34; if [ -d \u0026#34;$SOURCE\u0026#34; ]; then mkdir -p \u0026#34;$DEST\u0026#34; rsync -av --delete \u0026#34;$SOURCE/\u0026#34; \u0026#34;$DEST/\u0026#34; || log \u0026#34;Error en $volume\u0026#34; fi done log \u0026#34;Generando checksums\u0026#34; find \u0026#34;$BACKUP_DIR\u0026#34; -type f -exec md5sum {} \\; \u0026gt; \u0026#34;$CHECKSUM_FILE\u0026#34; 2\u0026gt;/dev/null || true log \u0026#34;Sincronizando con servidor remoto\u0026#34; rsync -avz \\ --delete \\ -e \u0026#34;ssh -i $SSH_KEY -o StrictHostKeyChecking=no\u0026#34; \\ \u0026#34;$BACKUP_DIR/\u0026#34; \\ \u0026#34;${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/\u0026#34; if [ $? -eq 0 ]; then log \u0026#34;Backup completado exitosamente\u0026#34; # Limpiar backups locales más antiguos de 7 días find \u0026#34;$BACKUP_DIR\u0026#34; -type f -mtime +7 -delete else log \u0026#34;ERROR: Fallo en la sincronización remota\u0026#34; exit 1 fi Hago el script ejecutable:\nchmod +x /usr/local/bin/docker-backup.sh 3. Crear systemd service y timer # Creo /etc/systemd/system/docker-backup.service:\n[Unit] Description=Docker Volumes Backup Service After=docker.service [Service] Type=oneshot ExecStart=/usr/local/bin/docker-backup.sh StandardOutput=journal StandardError=journal Y /etc/systemd/system/docker-backup.timer:\n[Unit] Description=Daily Docker Backup Timer Requires=docker-backup.service [Timer] OnCalendar=daily OnCalendar=*-*-* 02:00:00 Persistent=true [Install] WantedBy=timers.target 4. Activar y verificar # systemctl daemon-reload systemctl enable docker-backup.timer systemctl start docker-backup.timer systemctl status docker-backup.timer Ver logs:\njournalctl -u docker-backup.service -f 5. Verificar integridad remota # En el servidor remoto, creo un script para validar:\n#!/bin/bash REMOTE_PATH=\u0026#34;/backups/docker\u0026#34; cd \u0026#34;$REMOTE_PATH\u0026#34; md5sum -c .checksums \u0026gt; /tmp/backup-verify.log 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then echo \u0026#34;Backups íntegros\u0026#34; | mail -s \u0026#34;Backup OK\u0026#34; admin@example.com else echo \u0026#34;ADVERTENCIA: Fallos en checksums\u0026#34; | mail -s \u0026#34;Backup FALLIDO\u0026#34; admin@example.com fi Lo ejecuto como cron diario.\nResultado # Llevo 6 meses con este sistema. Los backups se ejecutan automáticamente cada noche sin intervención. Los checksums me dan confianza en que los datos remoto están intactos. He recuperado volúmenes tres veces sin problemas.\nLo mejor: no he tenido que pensar en ello desde que lo configuré.\n","date":"June 12, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-con-rsync-y-systemd-timers/","section":"Posts","summary":"Automatizar backups incrementales de volúmenes Docker con rsync y systemd timers # Después de perder datos por un fallo de disco, decidí implementar un sistema robusto de backups para mis volúmenes Docker. La solución que uso hoy es simple, confiable y se ejecuta sin intervención manual.\nEl problema # Tener contenedores con datos persistentes sin respaldo es un riesgo que no me puedo permitir. Los backups deben ser:\n","title":"Automatizar backups incrementales de volúmenes Docker con rsync y systemd timers","type":"posts"},{"content":" El problema # Tenía PostgreSQL y Grafana corriendo en Docker con volúmenes persistentes. La idea de perder esos datos me mantenía despierto. Backups manuales no son confiables. Necesitaba algo automático, eficiente y verificable.\nDespués de probar varias opciones, me decidí por restic + S3 + cron jobs. Es directo y funciona.\nPor qué restic # Restic hace backups incrementales deduplicados. Solo envía lo que cambió. Es perfecto para volúmenes Docker que típicamente tienen pequeños cambios día a día. Además verifica integridad automáticamente.\nSetup básico # 1. Instalar restic # sudo apt-get update sudo apt-get install restic 2. Crear credenciales S3 # Necesitas acceso a un bucket S3 (o MinIO si usas self-hosted). Crea las credenciales con permisos limitados:\nexport AWS_ACCESS_KEY_ID=\u0026#34;tu_access_key\u0026#34; export AWS_SECRET_ACCESS_KEY=\u0026#34;tu_secret_key\u0026#34; export AWS_DEFAULT_REGION=\u0026#34;us-east-1\u0026#34; Guarda esto en un archivo seguro (/root/.restic-env):\nexport AWS_ACCESS_KEY_ID=\u0026#34;...\u0026#34; export AWS_SECRET_ACCESS_KEY=\u0026#34;...\u0026#34; export RESTIC_REPOSITORY=\u0026#34;s3:https://s3.amazonaws.com/tu-bucket/backups\u0026#34; export RESTIC_PASSWORD=\u0026#34;tu_password_restic_fuerte\u0026#34; Permisos: chmod 600 /root/.restic-env\n3. Inicializar el repositorio # source /root/.restic-env restic init Script de backup # Crea /opt/backup-docker.sh:\n#!/bin/bash source /root/.restic-env BACKUP_DIRS=( \u0026#34;/var/lib/docker/volumes/postgres_data/_data\u0026#34; \u0026#34;/var/lib/docker/volumes/grafana_data/_data\u0026#34; ) TIMESTAMP=$(date +%Y-%m-%d_%H:%M:%S) LOG_FILE=\u0026#34;/var/log/restic-backup.log\u0026#34; echo \u0026#34;=== Backup iniciado: $TIMESTAMP ===\u0026#34; \u0026gt;\u0026gt; $LOG_FILE for dir in \u0026#34;${BACKUP_DIRS[@]}\u0026#34;; do if [ -d \u0026#34;$dir\u0026#34; ]; then echo \u0026#34;Backupeando: $dir\u0026#34; \u0026gt;\u0026gt; $LOG_FILE restic backup \u0026#34;$dir\u0026#34; --tag \u0026#34;docker\u0026#34; --tag \u0026#34;$(date +%A)\u0026#34; \u0026gt;\u0026gt; $LOG_FILE 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then echo \u0026#34;✓ Backup exitoso: $dir\u0026#34; \u0026gt;\u0026gt; $LOG_FILE else echo \u0026#34;✗ Error en backup: $dir\u0026#34; \u0026gt;\u0026gt; $LOG_FILE # Aquí podrías enviar una notificación fi fi done echo \u0026#34;=== Backup completado ===\u0026#34; \u0026gt;\u0026gt; $LOG_FILE Permisos:\nchmod +x /opt/backup-docker.sh Automatizar con cron # Edita la crontab:\ncrontab -e Agrega:\n# Backup diario a las 2 AM 0 2 * * * /opt/backup-docker.sh # Verificación de integridad cada domingo a las 3 AM 0 3 * * 0 source /root/.restic-env \u0026amp;\u0026amp; restic check --with-cache \u0026gt;\u0026gt; /var/log/restic-check.log 2\u0026gt;\u0026amp;1 # Limpieza de snapshots viejos cada mes 0 4 1 * * source /root/.restic-env \u0026amp;\u0026amp; restic forget --keep-daily 30 --keep-weekly 12 --prune \u0026gt;\u0026gt; /var/log/restic-prune.log 2\u0026gt;\u0026amp;1 Validación programada # La línea de restic check verifica que todos los datos estén íntegros. Es lo que duermes más tranquilo:\nsource /root/.restic-env restic check --with-cache Si hay corrupción, lo sabrás. Si todo está bien, ves esto:\nusing backend repository stored in s3:... checking integrity of repository [...] 100.00% checking snapshots, keys, buckets check successful, no errors were found Restaurar # Cuando lo necesites (espero que no):\nsource /root/.restic-env restic restore latest --target /tmp/restore O un snapshot específico:\nrestic snapshots # lista todos restic restore \u0026lt;snapshot-id\u0026gt; --target /tmp/restore En producción # Monitorea los logs:\ntail -f /var/log/restic-backup.log Considera agregar alertas si los backups fallan. Yo tengo un script que envía notificación a Discord si detecta errores.\nResultados # Después de tres meses:\nPostgreSQL: 2.3 GB comprimido Grafana: 150 MB comprimido Almacenamiento total en S3: 1.8 GB (deduplicación en acción) Los backups incrementales tardan entre 30 segundos y 2 minutos. Las verificaciones de integridad unos 5 minutos.\nEs confiable. Dormimos mejor.\nActualizado: 2026-06-10\n","date":"June 10, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-docker-con-restic-hacia-s3/","section":"Posts","summary":"El problema # Tenía PostgreSQL y Grafana corriendo en Docker con volúmenes persistentes. La idea de perder esos datos me mantenía despierto. Backups manuales no son confiables. Necesitaba algo automático, eficiente y verificable.\nDespués de probar varias opciones, me decidí por restic + S3 + cron jobs. Es directo y funciona.\nPor qué restic # Restic hace backups incrementales deduplicados. Solo envía lo que cambió. Es perfecto para volúmenes Docker que típicamente tienen pequeños cambios día a día. Además verifica integridad automáticamente.\n","title":"Automatizar backups incrementales de Docker con restic hacia S3","type":"posts"},{"content":"","date":"June 10, 2026","externalUrl":null,"permalink":"/tags/s3/","section":"Tags","summary":"","title":"S3","type":"tags"},{"content":" El problema # Tenía varios contenedores Docker con datos importantes: bases de datos, archivos de configuración, datos de aplicaciones. Necesitaba backups automáticos, incrementales y con retención inteligente. No quería gastar mucho en almacenamiento externo.\nLa solución: restic + S3. Restic es pequeño, eficiente y maneja incrementales nativamente. S3 es barato y confiable.\nSetup inicial # Primero instalé restic en el host:\ncurl -b /tmp/restic.bz2 -L https://github.com/restic/restic/releases/download/v0.16.0/restic_0.16.0_linux_amd64.bz2 cd /tmp \u0026amp;\u0026amp; bunzip2 restic.bz2 \u0026amp;\u0026amp; mv restic /usr/local/bin/ chmod +x /usr/local/bin/restic Creé credenciales de AWS IAM con permisos solo para S3:\n{ \u0026#34;Version\u0026#34;: \u0026#34;2012-10-17\u0026#34;, \u0026#34;Statement\u0026#34;: [ { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;s3:ListBucket\u0026#34;, \u0026#34;s3:GetBucketVersioning\u0026#34;, \u0026#34;s3:ListBucketVersions\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;arn:aws:s3:::mi-bucket-backups\u0026#34; }, { \u0026#34;Effect\u0026#34;: \u0026#34;Allow\u0026#34;, \u0026#34;Action\u0026#34;: [ \u0026#34;s3:GetObject\u0026#34;, \u0026#34;s3:PutObject\u0026#34;, \u0026#34;s3:DeleteObject\u0026#34; ], \u0026#34;Resource\u0026#34;: \u0026#34;arn:aws:s3:::mi-bucket-backups/*\u0026#34; } ] } Inicializar repositorio restic # export AWS_ACCESS_KEY_ID=\u0026#34;tu_access_key\u0026#34; export AWS_SECRET_ACCESS_KEY=\u0026#34;tu_secret_key\u0026#34; export RESTIC_PASSWORD=\u0026#34;contraseña_muy_segura\u0026#34; restic -r s3:s3.amazonaws.com/mi-bucket-backups init Guardé estas variables en /etc/restic/env con permisos 600.\nScript de backup # Creé /usr/local/bin/backup-docker-volumes.sh:\n#!/bin/bash source /etc/restic/env TIMESTAMP=$(date +%Y%m%d_%H%M%S) LOG_FILE=\u0026#34;/var/log/restic-backup-${TIMESTAMP}.log\u0026#34; # Array de volúmenes a respaldar VOLUMES=( \u0026#34;postgres_data\u0026#34; \u0026#34;nginx_config\u0026#34; \u0026#34;app_data\u0026#34; ) # Montar volúmenes temporalmente MOUNT_POINT=\u0026#34;/mnt/backup_volumes\u0026#34; mkdir -p ${MOUNT_POINT} for VOLUME in \u0026#34;${VOLUMES[@]}\u0026#34;; do VOLUME_PATH=\u0026#34;${MOUNT_POINT}/${VOLUME}\u0026#34; mkdir -p ${VOLUME_PATH} docker run --rm -v ${VOLUME}:/data -v ${MOUNT_POINT}:/mnt alpine \\ cp -r /data/* /mnt/${VOLUME}/ 2\u0026gt;/dev/null || true done # Ejecutar backup incremental restic -r s3:s3.amazonaws.com/mi-bucket-backups backup ${MOUNT_POINT} \\ --tag docker-volumes \\ --tag $(date +%Y-%m-%d) \\ \u0026gt;\u0026gt; ${LOG_FILE} 2\u0026gt;\u0026amp;1 BACKUP_STATUS=$? # Limpiar rm -rf ${MOUNT_POINT} # Rotación: mantener últimos 30 días diarios, 12 meses semanales restic -r s3:s3.amazonaws.com/mi-bucket-backups forget \\ --keep-daily 30 \\ --keep-weekly 12 \\ --keep-monthly 6 \\ --prune \\ \u0026gt;\u0026gt; ${LOG_FILE} 2\u0026gt;\u0026amp;1 if [ $BACKUP_STATUS -eq 0 ]; then echo \u0026#34;Backup exitoso\u0026#34; \u0026gt;\u0026gt; ${LOG_FILE} exit 0 else echo \u0026#34;Error en backup\u0026#34; \u0026gt;\u0026gt; ${LOG_FILE} exit 1 fi Lo hice ejecutable:\nchmod +x /usr/local/bin/backup-docker-volumes.sh Automatizar con systemd # Creé /etc/systemd/system/docker-backup.service:\n[Unit] Description=Docker Volumes Backup to S3 After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/bin/backup-docker-volumes.sh StandardOutput=journal StandardError=journal Y el timer en /etc/systemd/system/docker-backup.timer:\n[Unit] Description=Daily Docker Backup Timer [Timer] OnCalendar=daily OnCalendar=*-*-* 02:00:00 Persistent=true [Install] WantedBy=timers.target Activé el timer:\nsystemctl daemon-reload systemctl enable docker-backup.timer systemctl start docker-backup.timer Verificación y monitoreo # Para ver el estado:\nsystemctl status docker-backup.timer journalctl -u docker-backup.service -f Para listar snapshots:\nrestic -r s3:s3.amazonaws.com/mi-bucket-backups snapshots Para restaurar un volumen específico:\nrestic -r s3:s3.amazonaws.com/mi-bucket-backups restore latest \\ --target /tmp/restore \\ --path /mnt/backup_volumes/postgres_data Resultado final # Los backups corren automáticamente cada madrugada. Restic comprime y deduplica, así que el almacenamiento en S3 es mínimo. La rotación automática mantiene 30 días de backups diarios sin acumular basura.\nLlevó una tarde configurarlo, pero ahora duermo tranquilo.\n","date":"June 8, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-con-restic-y-s3/","section":"Posts","summary":"El problema # Tenía varios contenedores Docker con datos importantes: bases de datos, archivos de configuración, datos de aplicaciones. Necesitaba backups automáticos, incrementales y con retención inteligente. No quería gastar mucho en almacenamiento externo.\nLa solución: restic + S3. Restic es pequeño, eficiente y maneja incrementales nativamente. S3 es barato y confiable.\nSetup inicial # Primero instalé restic en el host:\n","title":"Automatizar backups incrementales de volúmenes Docker con restic y S3","type":"posts"},{"content":"Tenía un problema real: mis datos en volúmenes Docker estaban solo en el servidor local. Un fallo de disco y perdía todo. Necesitaba backups automáticos, incrementales y notificaciones cuando algo fallara. Aquí está lo que implementé.\nEl plan # Usar rsync para copiar solo lo que cambió, ejecutarlo cada noche con cron, y recibir un email si algo falla. Simple y sin dependencias raras.\nPaso 1: Preparar el almacenamiento remoto # Acceso SSH al servidor remoto configurado sin contraseña (con claves). En mi caso, un NAS en la red.\nssh-keygen -t ed25519 -f ~/.ssh/backup_key -N \u0026#34;\u0026#34; ssh-copy-id -i ~/.ssh/backup_key user@nas.local Crear el directorio destino:\nssh -i ~/.ssh/backup_key user@nas.local mkdir -p /backups/docker-volumes Paso 2: Crear el script de backup # Archivo: /opt/backup/backup-docker-volumes.sh\n#!/bin/bash BACKUP_HOST=\u0026#34;user@nas.local\u0026#34; BACKUP_PATH=\u0026#34;/backups/docker-volumes\u0026#34; SSH_KEY=\u0026#34;/root/.ssh/backup_key\u0026#34; DOCKER_VOLUMES_PATH=\u0026#34;/var/lib/docker/volumes\u0026#34; LOG_FILE=\u0026#34;/var/log/docker-backup.log\u0026#34; EMAIL=\u0026#34;admin@example.local\u0026#34; # Función para registrar log() { echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] $1\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; } # Función para enviar alertas send_alert() { local subject=\u0026#34;$1\u0026#34; local message=\u0026#34;$2\u0026#34; echo \u0026#34;$message\u0026#34; | mail -s \u0026#34;$subject\u0026#34; \u0026#34;$EMAIL\u0026#34; } log \u0026#34;Iniciando backup de volúmenes Docker\u0026#34; # Listar y respaldar cada volumen for volume in $(docker volume ls --quiet); do VOLUME_PATH=\u0026#34;$DOCKER_VOLUMES_PATH/$volume/_data\u0026#34; if [ ! -d \u0026#34;$VOLUME_PATH\u0026#34; ]; then log \u0026#34;ADVERTENCIA: No se encontró $VOLUME_PATH\u0026#34; continue fi log \u0026#34;Respaldando volumen: $volume\u0026#34; rsync -avz --delete \\ -e \u0026#34;ssh -i $SSH_KEY -o StrictHostKeyChecking=no\u0026#34; \\ \u0026#34;$VOLUME_PATH/\u0026#34; \\ \u0026#34;$BACKUP_HOST:$BACKUP_PATH/$volume/\u0026#34; \\ \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then log \u0026#34;✓ Volumen $volume respaldado correctamente\u0026#34; else log \u0026#34;✗ ERROR respaldando $volume\u0026#34; send_alert \u0026#34;FALLO: Backup Docker - $volume\u0026#34; \\ \u0026#34;El backup del volumen $volume falló. Revisa $LOG_FILE\u0026#34; exit 1 fi done log \u0026#34;Backup completado\u0026#34; send_alert \u0026#34;OK: Backup Docker completado\u0026#34; \u0026#34;Todos los volúmenes se respaldaron correctamente en $(date)\u0026#34; Dar permisos:\nchmod 755 /opt/backup/backup-docker-volumes.sh Paso 3: Configurar cron # Abre el crontab root:\ncrontab -e Añade esta línea para ejecutar cada noche a las 3 AM:\n0 3 * * * /opt/backup/backup-docker-volumes.sh Paso 4: Configurar el correo # Instala un MTA ligero. En Debian/Ubuntu:\napt-get install msmtp msmtp-mta Archivo de configuración: ~/.msmtprc\ndefaults auth on tls on tls_trust_file /etc/ssl/certs/ca-certificates.crt account gmail host smtp.gmail.com port 587 from docker-backup@example.local user tu_correo@gmail.com password tu_contraseña_app account default : gmail Permisos restringidos:\nchmod 600 ~/.msmtprc Paso 5: Prueba # /opt/backup/backup-docker-volumes.sh Verifica el log:\ntail -f /var/log/docker-backup.log Consideraciones reales # Espacio: Los primeros backups pesan. Monitorea el almacenamiento remoto.\nAncho de banda: rsync es eficiente, pero con volúmenes grandes nocturno es mejor.\nSeguridad: La clave SSH sin contraseña funciona, pero está en el servidor. Usa permisos restrictivos (600 en la clave).\nRotación: Después de 30 días, los incrementales se acumulan. Considera crear snapshots mensuales completos.\nVerificación: Una vez al mes, restaura algo de verdad. Un backup sin testar es un backup que no funciona.\nHe estado usando esto 8 meses. Solo ha fallado por timeout SSH (solución: aumenté el timeout en rsync). Los emails de confirmación diaria me dan paz mental.\n","date":"June 5, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-a-almacenamiento-remoto-con-rsync-y-cron/","section":"Posts","summary":"Tenía un problema real: mis datos en volúmenes Docker estaban solo en el servidor local. Un fallo de disco y perdía todo. Necesitaba backups automáticos, incrementales y notificaciones cuando algo fallara. Aquí está lo que implementé.\nEl plan # Usar rsync para copiar solo lo que cambió, ejecutarlo cada noche con cron, y recibir un email si algo falla. Simple y sin dependencias raras.\n","title":"Automatizar backups incrementales de volúmenes Docker a almacenamiento remoto con rsync y cron","type":"posts"},{"content":" Por qué necesitas backups remotos # Tenía dos contenedores críticos corriendo en mi servidor doméstico: Grafana con históricos de métricas y Listmonk manejando mi lista de correos. La idea de perder esos datos me mantenía despierto. Los backups locales no son suficientes. Necesitaba algo remoto, automatizado y que no me costara una fortuna.\nRestic + S3 (o compatible con S3) es la combinación que elegí. Restic es eficiente, soporta deduplicación y cifrado. S3 es accesible y barato. Aquí va lo que aprendí implementándolo.\nInstalación de restic # Primero, instalé restic en la máquina host:\nwget https://github.com/restic/restic/releases/download/v0.16.0/restic_0.16.0_linux_amd64.bz2 bzip2 -d restic_0.16.0_linux_amd64.bz2 mv restic_0.16.0_linux_amd64 /usr/local/bin/restic chmod +x /usr/local/bin/restic restic version Configurar credenciales de S3 # Creé un archivo de configuración con las credenciales. Usé Minio en mi caso (compatible con S3), pero funciona igual con AWS S3:\ncat \u0026gt; /root/.restic-env \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; export AWS_ACCESS_KEY_ID=\u0026#34;tu_access_key\u0026#34; export AWS_SECRET_ACCESS_KEY=\u0026#34;tu_secret_key\u0026#34; export RESTIC_REPOSITORY=\u0026#34;s3:https://minio.dominio.local/backups/docker\u0026#34; export RESTIC_PASSWORD=\u0026#34;contraseña_fuerte_para_restic\u0026#34; EOF chmod 600 /root/.restic-env Inicializar el repositorio # source /root/.restic-env restic init Restic crea la estructura necesaria en S3. Solo se hace una vez.\nEstrategia de backup para Docker # Para Grafana y Listmonk necesitaba extraer los volúmenes. Creé un script que:\nDetiene temporalmente los contenedores Copia los volúmenes a un directorio temporal Ejecuta restic Reinicia los contenedores cat \u0026gt; /usr/local/bin/docker-backup.sh \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; #!/bin/bash source /root/.restic-env BACKUP_DIR=\u0026#34;/tmp/docker-backup\u0026#34; CONTAINERS=(\u0026#34;grafana\u0026#34; \u0026#34;listmonk\u0026#34;) mkdir -p $BACKUP_DIR for container in \u0026#34;${CONTAINERS[@]}\u0026#34;; do echo \u0026#34;[$(date)] Deteniendo $container...\u0026#34; docker stop $container echo \u0026#34;[$(date)] Backupeando volúmenes de $container...\u0026#34; docker run --rm -v ${container}-storage:/data -v $BACKUP_DIR:/backup \\ alpine tar -czf /backup/${container}.tar.gz -C /data . echo \u0026#34;[$(date)] Iniciando $container...\u0026#34; docker start $container done echo \u0026#34;[$(date)] Ejecutando restic backup...\u0026#34; restic backup $BACKUP_DIR --tag=\u0026#34;docker-containers\u0026#34; --cleanup-cache echo \u0026#34;[$(date)] Backup completado\u0026#34; echo \u0026#34;[$(date)] Limpiando archivos temporales...\u0026#34; rm -rf $BACKUP_DIR restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune EOF chmod +x /usr/local/bin/docker-backup.sh Automatizar con cron # # Backup diario a las 2 AM 0 2 * * * /usr/local/bin/docker-backup.sh \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 Verificar y restaurar # Para estar seguro, verifico regularmente:\nsource /root/.restic-env restic snapshots restic check Para restaurar en caso de desastre:\nsource /root/.restic-env restic restore latest --target /tmp/restore-point Después extraería los tar.gz en los volúmenes correspondientes.\nLo que aprendí # No confíes solo en backups locales. Un fallo del disco afecta todo. Cifra los datos en tránsito. Restic lo hace automáticamente. Verifica los backups. He visto casos donde fallaban sin aviso. Ten política de retención. No necesito backups de hace 2 años. Monitorea los logs. Agrego alertas si el backup falla. Consideraciones finales # Esta configuración me permite dormir tranquilo. Los backups corren automaticamente, están cifrados, deduplicados y almacenados remotamente. El único punto débil es confiar en que S3 no falla, pero eso es aceptable para un servidor doméstico.\nSi usas AWS S3 puro, el costo es mínimo. Si usas Minio o Backblaze B2, aún mejor.\n","date":"June 3, 2026","externalUrl":null,"permalink":"/posts/backups-automatizados-con-restic-y-s3-protegiendo-grafana-y-listmonk-en-docker/","section":"Posts","summary":"Por qué necesitas backups remotos # Tenía dos contenedores críticos corriendo en mi servidor doméstico: Grafana con históricos de métricas y Listmonk manejando mi lista de correos. La idea de perder esos datos me mantenía despierto. Los backups locales no son suficientes. Necesitaba algo remoto, automatizado y que no me costara una fortuna.\nRestic + S3 (o compatible con S3) es la combinación que elegí. Restic es eficiente, soporta deduplicación y cifrado. S3 es accesible y barato. Aquí va lo que aprendí implementándolo.\n","title":"Backups automatizados con restic y S3: Protegiendo Grafana y Listmonk en Docker","type":"posts"},{"content":" El problema # Tenía varios contenedores Docker corriendo servicios críticos en mi servidor doméstico. La idea de perder datos por un fallo de disco me mantenía despierto. Necesitaba una solución que:\nFuera incremental (no copiar todo cada vez) Se ejecutara automáticamente sin intervención Permitiera rotación de copias antiguas Guardara los datos en almacenamiento remoto Después de probar varias opciones, restic fue la respuesta. Es rápido, eficiente y se integra bien con cron.\nInstalación de restic # Lo primero es instalar restic en el servidor:\ncurl -sL https://api.github.com/repos/restic/restic/releases/latest | grep -oP \u0026#39;\u0026#34;browser_download_url\u0026#34;: \u0026#34;\\K.*linux_amd64\\.bz2\u0026#39; | head -1 | xargs wget -O - | bunzip2 \u0026gt; /usr/local/bin/restic chmod +x /usr/local/bin/restic restic version También necesito jq para procesar JSON en los scripts:\napt-get install jq Configurar almacenamiento remoto # Usé un servidor SFTP remoto, pero restic soporta S3, B2, Google Cloud, etc.\nPrimero creo el repositorio:\nexport RESTIC_REPOSITORY=\u0026#34;sftp:usuario@servidor.remoto:/ruta/backups/docker\u0026#34; export RESTIC_PASSWORD=\u0026#34;contraseña_super_segura\u0026#34; restic init Guardo las credenciales en un archivo protegido:\ncat \u0026gt; /root/.restic_env \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; export RESTIC_REPOSITORY=\u0026#34;sftp:usuario@servidor.remoto:/ruta/backups/docker\u0026#34; export RESTIC_PASSWORD=\u0026#34;contraseña_super_segura\u0026#34; export RESTIC_CACHE_DIR=\u0026#34;/var/cache/restic\u0026#34; EOF chmod 600 /root/.restic_env Script de backup # Creo el script principal en /usr/local/bin/docker-backup.sh:\n#!/bin/bash source /root/.restic_env # Exportar volúmenes de contenedores específicos CONTAINERS=(\u0026#34;contenedor1\u0026#34; \u0026#34;contenedor2\u0026#34; \u0026#34;contenedor3\u0026#34;) BACKUP_DIR=\u0026#34;/tmp/docker-backup\u0026#34; mkdir -p \u0026#34;$BACKUP_DIR\u0026#34; for container in \u0026#34;${CONTAINERS[@]}\u0026#34;; do echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Exportando $container...\u0026#34; docker export \u0026#34;$container\u0026#34; | gzip \u0026gt; \u0026#34;$BACKUP_DIR/$container.tar.gz\u0026#34; done # Backup de directorios de volúmenes echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Iniciando backup con restic...\u0026#34; restic backup \u0026#34;$BACKUP_DIR\u0026#34; \\ --tag \u0026#34;docker-backup\u0026#34; \\ --tag \u0026#34;$(date +\u0026#39;%Y-%m-%d\u0026#39;)\u0026#34; \\ --verbose if [ $? -eq 0 ]; then echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Backup exitoso\u0026#34; rm -rf \u0026#34;$BACKUP_DIR\u0026#34; else echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Error en backup\u0026#34; \u0026gt;\u0026amp;2 exit 1 fi Lo hago ejecutable:\nchmod +x /usr/local/bin/docker-backup.sh Script de rotación # Creo otro script para limpiar backups antiguos en /usr/local/bin/docker-backup-prune.sh:\n#!/bin/bash source /root/.restic_env echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Iniciando limpieza de backups antiguos...\u0026#34; # Mantener últimos 30 backups diarios y 12 mensuales restic forget \\ --keep-daily 30 \\ --keep-monthly 12 \\ --keep-yearly 2 \\ --tag \u0026#34;docker-backup\u0026#34; \\ --prune \\ --verbose echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Limpieza completada\u0026#34; chmod +x /usr/local/bin/docker-backup-prune.sh Configurar cron # Edito la crontab del root:\ncrontab -e Agrego las siguientes líneas:\n# Backup diario a las 2 AM 0 2 * * * /usr/local/bin/docker-backup.sh \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 # Rotación de backups cada domingo a las 3 AM 0 3 * * 0 /usr/local/bin/docker-backup-prune.sh \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 Monitoreo # Para saber qué está pasando, reviso los logs regularmente:\ntail -f /var/log/docker-backup.log También puedo verificar el estado del repositorio:\nsource /root/.restic_env restic snapshots restic stats Resultado # Después de una semana funcionando, tengo:\nBackups incrementales diarios sin intervención manual 30 días de historiales disponibles Rotación automática de copias antiguas Datos replicados en servidor remoto seguro Esto me quitó la preocupación. Los contenedores están protegidos.\n","date":"June 1, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-docker-con-restic-y-cron/","section":"Posts","summary":"El problema # Tenía varios contenedores Docker corriendo servicios críticos en mi servidor doméstico. La idea de perder datos por un fallo de disco me mantenía despierto. Necesitaba una solución que:\nFuera incremental (no copiar todo cada vez) Se ejecutara automáticamente sin intervención Permitiera rotación de copias antiguas Guardara los datos en almacenamiento remoto Después de probar varias opciones, restic fue la respuesta. Es rápido, eficiente y se integra bien con cron.\n","title":"Automatizar backups incrementales de Docker con restic y cron","type":"posts"},{"content":"","date":"May 29, 2026","externalUrl":null,"permalink":"/tags/almacenamiento/","section":"Tags","summary":"","title":"Almacenamiento","type":"tags"},{"content":" El problema real # Tenía PostgreSQL corriendo en Docker y los backups manuales no escalan. Necesitaba algo automático, que verificara que los datos estuvieran bien y que limpiara lo viejo sin intervención.\nLa solución que implementé # Creé un script bash que:\nGenera backups incrementales en formato custom (comprimido) Verifica la integridad de cada copia Mantiene solo los últimos N backups Corre vía cron en horarios específicos Paso 1: Preparar el almacenamiento externo # Monté un disco externo en /mnt/backups-postgres/:\nsudo mkdir -p /mnt/backups-postgres sudo chown $USER:$USER /mnt/backups-postgres chmod 700 /mnt/backups-postgres Cada día un subdirectorio separado:\nmkdir -p /mnt/backups-postgres/$(date +%Y-%m-%d) Paso 2: El script de backup # Creé /home/rogelio/scripts/backup-postgres.sh:\n#!/bin/bash BACKUP_DIR=\u0026#34;/mnt/backups-postgres/$(date +%Y-%m-%d)\u0026#34; CONTAINER_NAME=\u0026#34;postgres-prod\u0026#34; DB_USER=\u0026#34;postgres\u0026#34; TIMESTAMP=$(date +%H-%M-%S) BACKUP_FILE=\u0026#34;$BACKUP_DIR/backup-$TIMESTAMP.custom\u0026#34; LOG_FILE=\u0026#34;$BACKUP_DIR/backup-$TIMESTAMP.log\u0026#34; # Crear directorio del día mkdir -p \u0026#34;$BACKUP_DIR\u0026#34; # Ejecutar dump incrementa (custom format) docker exec $CONTAINER_NAME pg_dump \\ -U $DB_USER \\ -Fc \\ --verbose \\ --compress=9 \\ postgres \u0026gt; \u0026#34;$BACKUP_FILE\u0026#34; 2\u0026gt; \u0026#34;$LOG_FILE\u0026#34; if [ $? -eq 0 ]; then echo \u0026#34;✓ Backup exitoso: $BACKUP_FILE\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; # Verificación de integridad docker exec $CONTAINER_NAME pg_restore \\ -U $DB_USER \\ --list \u0026#34;$BACKUP_FILE\u0026#34; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 if [ $? -eq 0 ]; then echo \u0026#34;✓ Verificación OK\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; echo \u0026#34;$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;) - Backup exitoso y verificado\u0026#34; \u0026gt;\u0026gt; \u0026#34;$BACKUP_DIR/resumen.log\u0026#34; else echo \u0026#34;✗ Verificación fallida\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; exit 1 fi else echo \u0026#34;✗ Error en backup\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; exit 1 fi Paso 3: Rotación de backups antiguos # Agregué al script una función de limpieza:\n# Mantener solo 7 días de backups RETENTION_DAYS=7 find /mnt/backups-postgres -maxdepth 1 -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \\; 2\u0026gt;/dev/null echo \u0026#34;Rotación completada: directorios mayores a $RETENTION_DAYS días eliminados\u0026#34; Paso 4: Automatizar con cron # crontab -e Agregué estas líneas:\n# Backup cada 6 horas 0 0,6,12,18 * * * /home/rogelio/scripts/backup-postgres.sh # Verificación semanal de integridad 0 3 * * 0 /home/rogelio/scripts/verify-backups.sh Paso 5: Script de verificación adicional # Creé /home/rogelio/scripts/verify-backups.sh para validar todos los backups de una vez:\n#!/bin/bash BACKUP_DIR=\u0026#34;/mnt/backups-postgres\u0026#34; FAILED=0 for backup_file in $(find $BACKUP_DIR -name \u0026#34;*.custom\u0026#34; -type f); do docker exec postgres-prod pg_restore \\ -U postgres \\ --list \u0026#34;$backup_file\u0026#34; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 if [ $? -ne 0 ]; then echo \u0026#34;✗ Fallo verificación: $backup_file\u0026#34; FAILED=$((FAILED + 1)) fi done if [ $FAILED -eq 0 ]; then echo \u0026#34;✓ Todos los backups verificados correctamente\u0026#34; else echo \u0026#34;✗ $FAILED backups fallaron verificación\u0026#34; # Aquí podrías enviar alerta por correo exit 1 fi Monitoreo real # Los logs quedan en cada directorio. Reviso el resumen:\ntail -f /mnt/backups-postgres/$(date +%Y-%m-%d)/resumen.log Lo que aprendí # El formato custom es mejor que SQL plano: comprime más y permite restaurar tablas específicas Validar con pg_restore --list es rápido y confiable No confíes solo en el cron: agrega logs para verificar que corre El almacenamiento externo montado directamente es más rápido que NAS Llevo 6 meses con esto sin problemas. Los backups incrementales con rotación automática funcionan sin intervención y la verificación de integridad te da tranquilidad real.\n","date":"May 29, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-postgresql-en-docker-con-verificacion-y-rotacion/","section":"Posts","summary":"El problema real # Tenía PostgreSQL corriendo en Docker y los backups manuales no escalan. Necesitaba algo automático, que verificara que los datos estuvieran bien y que limpiara lo viejo sin intervención.\nLa solución que implementé # Creé un script bash que:\nGenera backups incrementales en formato custom (comprimido) Verifica la integridad de cada copia Mantiene solo los últimos N backups Corre vía cron en horarios específicos Paso 1: Preparar el almacenamiento externo # Monté un disco externo en /mnt/backups-postgres/:\n","title":"Automatizar backups incrementales de PostgreSQL en Docker con verificación y rotación","type":"posts"},{"content":" El problema # Tenía contenedores Docker corriendo en el servidor doméstico con datos que no podía perder. Los backups manuales no escalan y dejar todo en el almacenamiento local es arriesgado. Necesitaba automatizar backups incrementales a un NAS remoto y poder verificar que todo estuviera bien desde el móvil sin exponer el servidor directamente a Internet.\nLa solución: restic + cron + Wireguard # Restic es perfecto para esto. Hace backups incrementales comprimidos, verifica integridad automáticamente y soporta múltiples backends remotos. Lo configuré así:\n1. Instalación y preparación # sudo apt install restic Cré un repositorio restic en el NAS remoto accesible vía Wireguard:\nrestic -r sftp:usuario@nas-interno:/backups/docker init Generé una contraseña fuerte y la guardé en /root/.restic/password:\nmkdir -p /root/.restic echo \u0026#34;tu_contraseña_super_segura\u0026#34; \u0026gt; /root/.restic/password chmod 600 /root/.restic/password 2. Script de backup de volúmenes # Creé /usr/local/bin/backup-docker-volumes.sh:\n#!/bin/bash export RESTIC_REPOSITORY=\u0026#34;sftp:usuario@nas-interno:/backups/docker\u0026#34; export RESTIC_PASSWORD_FILE=\u0026#34;/root/.restic/password\u0026#34; # Array con los volúmenes a respaldar VOLUMES=(\u0026#34;postgres_data\u0026#34; \u0026#34;mongodb_data\u0026#34; \u0026#34;app_config\u0026#34;) for volume in \u0026#34;${VOLUMES[@]}\u0026#34;; do echo \u0026#34;[$(date +\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;)] Respaldando volumen: $volume\u0026#34; # Obtener la ruta de montaje del volumen VOLUME_PATH=$(docker volume inspect \u0026#34;$volume\u0026#34; \\ --format=\u0026#39;{{.Mountpoint}}\u0026#39;) # Realizar backup con restic restic backup \u0026#34;$VOLUME_PATH\u0026#34; \\ --tag \u0026#34;docker_$volume\u0026#34; \\ --exclude=\u0026#34;/lost+found\u0026#34; \\ --exclude=\u0026#34;*.tmp\u0026#34; if [ $? -eq 0 ]; then echo \u0026#34;✓ Backup de $volume completado\u0026#34; else echo \u0026#34;✗ Error en backup de $volume\u0026#34; # Aquí puedes agregar notificación por email fi done # Limpiar snapshots antiguos (mantener 7 días) restic forget --keep-daily 7 --prune Permisos:\nchmod +x /usr/local/bin/backup-docker-volumes.sh 3. Automatización con cron # En el crontab del root (crontab -e):\n# Backup diario a las 2:00 AM 0 2 * * * /usr/local/bin/backup-docker-volumes.sh \u0026gt;\u0026gt; /var/log/docker-backups.log 2\u0026gt;\u0026amp;1 4. Verificación desde el móvil vía Wireguard # Instalé la app de Wireguard en el móvil y configuré acceso al servidor doméstico. Una vez conectado a la VPN:\nCreé un script de verificación en /usr/local/bin/check-backups.sh:\n#!/bin/bash export RESTIC_REPOSITORY=\u0026#34;sftp:usuario@nas-interno:/backups/docker\u0026#34; export RESTIC_PASSWORD_FILE=\u0026#34;/root/.restic/password\u0026#34; echo \u0026#34;=== Estado de Backups ===\u0026#34; echo \u0026#34;Snapshots disponibles:\u0026#34; restic snapshots --json | jq -r \u0026#39;.[] | \u0026#34;\\(.time | split(\u0026#34;T\u0026#34;)[0]) - \\(.paths[0] | split(\u0026#34;/\u0026#34;)[-1]) (\\(.id[0:8]))\u0026#34;\u0026#39; echo \u0026#34;\u0026#34; echo \u0026#34;Verificando integridad...\u0026#34; restic check --with-cache echo \u0026#34;\u0026#34; echo \u0026#34;Estadísticas:\u0026#34; restic stats Accesible vía SSH desde cualquier cliente Wireguard:\nssh usuario@servidor-local \u0026#39;/usr/local/bin/check-backups.sh\u0026#39; 5. Monitoreo básico # Agregué un pequeño endpoint HTTP para monitoreo remoto. Script Python simple en /opt/backup-monitor.py:\n#!/usr/bin/env python3 from flask import Flask import subprocess import os app = Flask(__name__) @app.route(\u0026#39;/status\u0026#39;) def status(): try: result = subprocess.run( [\u0026#39;restic\u0026#39;, \u0026#39;snapshots\u0026#39;, \u0026#39;--json\u0026#39;], env={**os.environ, \u0026#39;RESTIC_REPOSITORY\u0026#39;: \u0026#39;sftp:...\u0026#39;, \u0026#39;RESTIC_PASSWORD_FILE\u0026#39;: \u0026#39;/root/.restic/password\u0026#39;}, capture_output=True ) return {\u0026#39;status\u0026#39;: \u0026#39;ok\u0026#39;, \u0026#39;backups\u0026#39;: len(result.stdout.decode().split(\u0026#39;\\n\u0026#39;))} except: return {\u0026#39;status\u0026#39;: \u0026#39;error\u0026#39;}, 500 if __name__ == \u0026#39;__main__\u0026#39;: app.run(host=\u0026#39;127.0.0.1\u0026#39;, port=5000) Lo ejecuto como servicio systemd accesible solo vía Wireguard.\nResultados en producción # Después de 3 meses:\nBackups consistentes: Sin fallos. Los logs están limpios. Espacio optimizado: La deduplicación de restic mantiene el almacenamiento en ~200GB para 6 meses de datos. Verificación desde móvil: Tardo 15 segundos en confirmar que todo está bien, desde cualquier lugar. Tranquilidad: Sé exactamente qué respaldar y no dejo nada a la improvisación. La combinación restic + Wireguard es muy potente. Sin exponer el servidor, tengo backups automatizados y verificables desde cualquier dispositivo. Vale la pena configurarlo bien desde el principio.\n","date":"May 27, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-volumenes-docker-a-almacenamiento-remoto-con-restic-y-verificar-integridad-desde-el-movil-via-wireguard/","section":"Posts","summary":"El problema # Tenía contenedores Docker corriendo en el servidor doméstico con datos que no podía perder. Los backups manuales no escalan y dejar todo en el almacenamiento local es arriesgado. Necesitaba automatizar backups incrementales a un NAS remoto y poder verificar que todo estuviera bien desde el móvil sin exponer el servidor directamente a Internet.\nLa solución: restic + cron + Wireguard # Restic es perfecto para esto. Hace backups incrementales comprimidos, verifica integridad automáticamente y soporta múltiples backends remotos. Lo configuré así:\n","title":"Automatizar backups incrementales de volúmenes Docker a almacenamiento remoto con restic y verificar integridad desde el móvil vía Wireguard","type":"posts"},{"content":" El problema # Tengo un PostgreSQL corriendo en Docker en mi servidor doméstico. Los datos son importantes. Los backups manuales son un desastre garantizado. Necesitaba algo automatizado, incremental y verificable sin exponer el servidor a internet.\nLa solución # Combiné cron, Docker, pg_dump y Wireguard para crear un sistema de backups que se ejecuta solo, verifica su integridad y me avisa si algo falla.\nPaso 1: Script de backup incremental # Primero, creé un script que genera backups con marca de tiempo y usa hard links para no duplicar datos innecesarios:\n#!/bin/bash BACKUP_DIR=\u0026#34;/mnt/backups/postgresql\u0026#34; DB_CONTAINER=\u0026#34;postgres-container\u0026#34; DB_NAME=\u0026#34;tu_base_datos\u0026#34; FULL_BACKUP_DAY=0 # Domingo RETENTION_DAYS=30 mkdir -p \u0026#34;$BACKUP_DIR\u0026#34; CURRENT_DAY=$(date +%u) BACKUP_DATE=$(date +%Y%m%d_%H%M%S) if [ \u0026#34;$CURRENT_DAY\u0026#34; -eq \u0026#34;$FULL_BACKUP_DAY\u0026#34; ]; then BACKUP_FILE=\u0026#34;$BACKUP_DIR/full_backup_$BACKUP_DATE.sql.gz\u0026#34; docker exec $DB_CONTAINER pg_dump -U postgres $DB_NAME | gzip \u0026gt; \u0026#34;$BACKUP_FILE\u0026#34; echo \u0026#34;Full backup creado: $BACKUP_FILE\u0026#34; else LAST_FULL=$(ls -t \u0026#34;$BACKUP_DIR\u0026#34;/full_backup_*.sql.gz 2\u0026gt;/dev/null | head -1) BACKUP_FILE=\u0026#34;$BACKUP_DIR/incr_backup_$BACKUP_DATE.sql.gz\u0026#34; docker exec $DB_CONTAINER pg_dump -U postgres $DB_NAME | gzip \u0026gt; \u0026#34;$BACKUP_FILE\u0026#34; echo \u0026#34;Backup incremental creado: $BACKUP_FILE\u0026#34; fi # Calcular hash para verificación sha256sum \u0026#34;$BACKUP_FILE\u0026#34; \u0026gt; \u0026#34;$BACKUP_FILE.sha256\u0026#34; # Limpiar backups antiguos find \u0026#34;$BACKUP_DIR\u0026#34; -name \u0026#34;*.sql.gz\u0026#34; -mtime +$RETENTION_DAYS -delete echo \u0026#34;Backup completado exitosamente\u0026#34; Guardé esto en /opt/backup-scripts/pg_backup.sh con permisos de ejecución:\nchmod +x /opt/backup-scripts/pg_backup.sh Paso 2: Configurar cron # Agregué la tarea a crontab para ejecutarse diariamente a las 2 AM:\ncrontab -e 0 2 * * * /opt/backup-scripts/pg_backup.sh \u0026gt;\u0026gt; /var/log/pg_backup.log 2\u0026gt;\u0026amp;1 Paso 3: Script de verificación con Wireguard # Ahora necesitaba verificar la integridad de los backups desde otra máquina a través de Wireguard. Creé un script de verificación:\n#!/bin/bash BACKUP_DIR=\u0026#34;/mnt/backups/postgresql\u0026#34; LOG_FILE=\u0026#34;/var/log/backup_verify.log\u0026#34; { echo \u0026#34;=== Verificación de backups - $(date) ===\u0026#34; for backup in \u0026#34;$BACKUP_DIR\u0026#34;/*.sql.gz; do if [ -f \u0026#34;$backup.sha256\u0026#34; ]; then if sha256sum -c \u0026#34;$backup.sha256\u0026#34; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1; then echo \u0026#34;[OK] $backup\u0026#34; else echo \u0026#34;[ERROR] Checksum fallido: $backup\u0026#34; # Aquí podrías enviar una alerta fi fi done } \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; Lo dejé en /opt/backup-scripts/verify_backups.sh y lo ejecuto semanalmente:\n0 3 * * 0 /opt/backup-scripts/verify_backups.sh Paso 4: Acceso seguro vía Wireguard # En mi servidor doméstico, solo la VPN de Wireguard tiene acceso a la ruta de backups. En el cliente Wireguard:\nMe conecto a la VPN Puedo acceder al servidor por su IP de Wireguard Ejecuto la verificación de forma segura sin exponer nada a internet ssh usuario@10.0.0.2 \u0026#34;sudo /opt/backup-scripts/verify_backups.sh\u0026#34; Monitoreo # Configuré alertas básicas editando el script de backup para notificar errores:\nif [ $? -ne 0 ]; then echo \u0026#34;Backup fallido\u0026#34; | mail -s \u0026#34;Error en backup PostgreSQL\u0026#34; mi@email.com fi Lo que aprendí # Los backups automáticos sin verificación son apenas mejores que nada Hard links ahorran espacio pero complican las cosas. Acabé usando compresión simple Wireguard es extremadamente util para acceso seguro sin VPN tradicionales Ejecutar backups a las 2 AM evita picos de carga He estado usando esta setup 6 meses. Funciona sin intervención. Los backups se crean, se verifican y se limpian solos. Eso es exactamente lo que quería.\n","date":"May 25, 2026","externalUrl":null,"permalink":"/posts/automatizar-backups-incrementales-de-postgresql-en-docker-con-cron-y-verificacion-en-wireguard/","section":"Posts","summary":"El problema # Tengo un PostgreSQL corriendo en Docker en mi servidor doméstico. Los datos son importantes. Los backups manuales son un desastre garantizado. Necesitaba algo automatizado, incremental y verificable sin exponer el servidor a internet.\nLa solución # Combiné cron, Docker, pg_dump y Wireguard para crear un sistema de backups que se ejecuta solo, verifica su integridad y me avisa si algo falla.\n","title":"Automatizar backups incrementales de PostgreSQL en Docker con cron y verificación en Wireguard","type":"posts"},{"content":"Un contenedor en estado running no significa que el servicio dentro funcione correctamente. He tenido casos en los que Nginx arrancaba, Docker lo veía verde, pero la aplicación devolvía 502 en cada petición. Sin health checks, eso pasa desapercibido hasta que lo detecta un usuario. Con health checks, Docker lo detecta en segundos y puede reiniciar el contenedor automáticamente.\nEl problema: running no es healthy # Por defecto, Docker solo sabe si el proceso principal del contenedor sigue vivo. Si tu aplicación arranca, entra en un bucle de error interno y sigue corriendo, Docker no tiene forma de saberlo.\n$ docker ps CONTAINER ID IMAGE STATUS PORTS a3f9c2b1d4e5 mi-app:1.0 Up 3 minutes 0.0.0.0:8080-\u0026gt;8080/tcp Up 3 minutes solo dice que el proceso no ha muerto. No dice nada sobre si responde peticiones HTTP, si la base de datos está accesible desde dentro del contenedor, o si la aplicación está en medio de un deadlock.\nHEALTHCHECK en el Dockerfile # La forma más portable de definir un health check es directamente en el Dockerfile. Así el check viaja con la imagen y no depende de cómo se despliegue:\nFROM python:3.12-slim WORKDIR /app COPY . . RUN pip install -r requirements.txt EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \\ CMD curl -f http://localhost:8080/health || exit 1 CMD [\u0026#34;python\u0026#34;, \u0026#34;-m\u0026#34;, \u0026#34;uvicorn\u0026#34;, \u0026#34;main:app\u0026#34;, \u0026#34;--host\u0026#34;, \u0026#34;0.0.0.0\u0026#34;, \u0026#34;--port\u0026#34;, \u0026#34;8080\u0026#34;] Los parámetros que importan:\nParámetro Valor por defecto Qué hace --interval 30s Cada cuánto ejecuta el check --timeout 30s Tiempo máximo para que el check responda --start-period 0s Tiempo de gracia al arrancar (no cuenta fallos) --retries 3 Fallos consecutivos antes de marcar como unhealthy El --start-period es importante: si tu aplicación tarda en arrancar (carga de modelos, migraciones de base de datos, precalentamiento de caché), necesitas darle ese margen o Docker la marcará como unhealthy antes de que esté lista.\nEl endpoint /health # Para que el health check sea útil, tu aplicación debería exponer un endpoint específico que compruebe más que \u0026ldquo;estoy vivo\u0026rdquo;. Un /health mínimo en FastAPI:\nfrom fastapi import FastAPI from sqlalchemy import text app = FastAPI() @app.get(\u0026#34;/health\u0026#34;) async def health_check(): # Comprueba que la base de datos responde try: db.execute(text(\u0026#34;SELECT 1\u0026#34;)) except Exception: return {\u0026#34;status\u0026#34;: \u0026#34;unhealthy\u0026#34;, \u0026#34;detail\u0026#34;: \u0026#34;db unreachable\u0026#34;}, 503 return {\u0026#34;status\u0026#34;: \u0026#34;ok\u0026#34;} Si el endpoint devuelve cualquier código que no sea 2xx, curl -f falla y el check falla.\nHEALTHCHECK en docker-compose.yml # Si no controlas el Dockerfile (imagen de terceros) o quieres sobrescribir el health check definido en la imagen, puedes hacerlo directamente en docker-compose.yml:\nservices: servidor-web: image: nginx:alpine ports: - \u0026#34;80:80\u0026#34; healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;-f\u0026#34;, \u0026#34;http://localhost/nginx_status\u0026#34;] interval: 30s timeout: 5s start_period: 10s retries: 3 base-de-datos: image: postgres:16 environment: POSTGRES_DB: mi_base_datos POSTGRES_USER: usuario POSTGRES_PASSWORD: TU_PASSWORD_AQUI healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U usuario -d mi_base_datos\u0026#34;] interval: 10s timeout: 5s start_period: 30s retries: 5 Nota la diferencia entre CMD y CMD-SHELL:\nCMD: ejecuta el comando directamente, sin shell. Más seguro y predecible. CMD-SHELL: ejecuta el comando a través de /bin/sh -c. Necesario cuando usas tuberías, variables o lógica shell. depends_on con condición de salud # Aquí está el verdadero poder de los health checks. Si tu aplicación necesita la base de datos para arrancar, puedes indicarle a Docker Compose que espere a que la base de datos esté healthy antes de iniciar la aplicación:\nservices: base-de-datos: image: postgres:16 environment: POSTGRES_DB: mi_app_db POSTGRES_USER: usuario POSTGRES_PASSWORD: TU_PASSWORD_AQUI healthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;pg_isready -U usuario -d mi_app_db\u0026#34;] interval: 10s timeout: 5s start_period: 30s retries: 5 mi-aplicacion: image: mi-app:latest ports: - \u0026#34;8080:8080\u0026#34; depends_on: base-de-datos: condition: service_healthy environment: DATABASE_URL: postgresql://usuario:TU_PASSWORD_AQUI@base-de-datos:5432/mi_app_db Con condition: service_healthy, Docker Compose no arrancará mi-aplicacion hasta que base-de-datos devuelva un health check exitoso. Sin esto, si la base de datos tarda 20 segundos en estar lista, la aplicación puede fallar al arrancar porque intenta conectarse antes de tiempo.\nLas tres condiciones disponibles son:\nservice_started → el contenedor simplemente está en marcha (comportamiento por defecto) service_healthy → el health check pasa service_completed_successfully → el contenedor ha terminado con código de salida 0 (para tareas de inicialización) Ver el estado de salud # Una vez que los contenedores tienen health check configurado, docker ps muestra el estado:\n$ docker ps CONTAINER ID IMAGE STATUS PORTS a3f9c2b1d4e5 mi-app:1.0 Up 5 minutes (healthy) 0.0.0.0:8080-\u0026gt;8080/tcp b1c4d5e6f7a8 postgres:16 Up 5 minutes (healthy) 5432/tcp c9d0e1f2a3b4 nginx:alpine Up 2 minutes (unhealthy) 0.0.0.0:80-\u0026gt;80/tcp Para ver el historial de los últimos checks de un contenedor:\ndocker inspect --format=\u0026#39;{{json .State.Health}}\u0026#39; mi-aplicacion | python3 -m json.tool Esto muestra los últimos 5 resultados con timestamp, código de salida y salida del comando. Muy útil para diagnosticar por qué un contenedor está marcado como unhealthy.\nReinicio automático con restart policies # Los health checks cobran su máximo valor combinados con la política de reinicio:\nservices: mi-aplicacion: image: mi-app:latest restart: unless-stopped healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;-f\u0026#34;, \u0026#34;http://localhost:8080/health\u0026#34;] interval: 30s timeout: 10s start_period: 20s retries: 3 Con restart: unless-stopped, Docker reiniciará el contenedor si el proceso muere. Pero no lo reinicia automáticamente si el health check falla. Para eso necesitas una herramienta adicional como Autoheal:\nservices: autoheal: image: willfarrell/autoheal:latest restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock environment: AUTOHEAL_CONTAINER_LABEL: autoheal AUTOHEAL_INTERVAL: 10 AUTOHEAL_START_PERIOD: 0 mi-aplicacion: image: mi-app:latest restart: unless-stopped labels: - \u0026#34;autoheal=true\u0026#34; healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;-f\u0026#34;, \u0026#34;http://localhost:8080/health\u0026#34;] interval: 30s timeout: 10s start_period: 20s retries: 3 Autoheal monitoriza los contenedores marcados con la etiqueta autoheal=true y los reinicia cuando pasan a estado unhealthy.\nHealth checks para servicios sin HTTP # No todo es HTTP. Algunos ejemplos habituales:\nRedis:\nhealthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;redis-cli\u0026#34;, \u0026#34;ping\u0026#34;] interval: 10s timeout: 3s retries: 3 MySQL/MariaDB:\nhealthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;mysqladmin\u0026#34;, \u0026#34;ping\u0026#34;, \u0026#34;-h\u0026#34;, \u0026#34;localhost\u0026#34;, \u0026#34;-u\u0026#34;, \u0026#34;root\u0026#34;, \u0026#34;--password=TU_PASSWORD_AQUI\u0026#34;] interval: 10s timeout: 5s start_period: 30s retries: 5 Verificación de puerto con netcat (cuando no hay cliente disponible en la imagen):\nhealthcheck: test: [\u0026#34;CMD-SHELL\u0026#34;, \u0026#34;nc -z localhost 8080 || exit 1\u0026#34;] interval: 15s timeout: 5s retries: 3 Script personalizado (para lógica compleja):\nhealthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;/usr/local/bin/check-health.sh\u0026#34;] interval: 30s timeout: 15s retries: 3 El script puede hacer múltiples comprobaciones: conectividad de red, disponibilidad de ficheros, espacio en disco, o cualquier condición que defina \u0026ldquo;sano\u0026rdquo; para ese servicio específico.\nDeshabilitar el health check de una imagen # Si una imagen base define un health check que no quieres, puedes desactivarlo:\nhealthcheck: disable: true Útil cuando heredas una imagen con un health check que no aplica a tu caso de uso o que genera falsos positivos.\nQué he aprendido en producción # start_period demasiado corto: el error más común. Una base de datos con muchos datos puede tardar más de lo esperado en arrancar. Empieza con un valor generoso y ajusta observando los logs. Health checks que consumen demasiado: un curl a un endpoint ligero cuesta muy poco. Un health check que lanza una query pesada cada 10 segundos puede añadir carga innecesaria. Mantén los checks simples y rápidos. No confundir readiness con liveness: en Kubernetes existe la distinción entre readiness (¿listo para recibir tráfico?) y liveness (¿sigue vivo?). En Docker puro, el health check cubre ambos, así que diseña el endpoint /health pensando en ambas preguntas. Logs del health check: cuando un contenedor está unhealthy y no sabes por qué, docker inspect con el filtro de Health te da los últimos resultados con la salida exacta del comando. Es la primera herramienta que busco. Los health checks son una de esas cosas que tardas diez minutos en configurar y te ahorran horas de diagnóstico. Si tienes servicios en producción sin ellos, este fin de semana es buen momento para añadirlos.\n","date":"May 25, 2026","externalUrl":null,"permalink":"/posts/docker-health-checks-contenedores-resilientes/","section":"Posts","summary":"Un contenedor en estado running no significa que el servicio dentro funcione correctamente. He tenido casos en los que Nginx arrancaba, Docker lo veía verde, pero la aplicación devolvía 502 en cada petición. Sin health checks, eso pasa desapercibido hasta que lo detecta un usuario. Con health checks, Docker lo detecta en segundos y puede reiniciar el contenedor automáticamente.\nEl problema: running no es healthy # Por defecto, Docker solo sabe si el proceso principal del contenedor sigue vivo. Si tu aplicación arranca, entra en un bucle de error interno y sigue corriendo, Docker no tiene forma de saberlo.\n","title":"Docker health checks: cómo detectar contenedores rotos antes de que te afecten","type":"posts"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/action1/","section":"Tags","summary":"","title":"Action1","type":"tags"},{"content":"Gestionar varios equipos Windows desde un único panel, sin instalar agentes pesados ni pagar licencias de RMM corporativo, es exactamente lo que resuelve Action1. Es gratuito para hasta 100 endpoints, corre en la nube de su proveedor —sin infraestructura propia que mantener— y el agente en el endpoint pesa menos de 5 MB.\nEste artículo no cubre la instalación: eso se hace en diez minutos siguiendo su asistente. Me centro en lo que realmente importa cuando ya lo tienes corriendo: cómo organizar y controlar los endpoints, cómo automatizar tareas repetitivas con scripts y cómo explotar el inventario para saber exactamente qué hay en cada máquina.\nGestión de endpoints # Una vez instalado el agente en cada equipo, todos aparecen en el panel bajo Endpoints. El estado (online/offline), el sistema operativo, la versión de Windows y el usuario activo se actualizan en tiempo real.\nLo más útil del panel de gestión:\nGrupos de endpoints. Puedes organizar los equipos por ubicación, cliente o función (servidores, portátiles, TPVs). Las políticas y los scripts programados se aplican por grupo, lo que es la base de todo lo demás.\nAcceso remoto integrado. Sin VPN ni herramientas externas. Desde el panel abres una sesión remota directamente en el navegador, con la latencia esperada según la conexión del endpoint.\nAlertas de salud. Disco lleno, servicio caído, sistema sin actualizaciones críticas. Las alertas llegan por email y se registran en el panel con historial.\nUsuarios activos en tiempo real. Útil para saber si un equipo está en uso antes de lanzar un script que interrumpa la sesión del usuario.\nLa lógica de grupos es la que da potencia al resto de funciones: cualquier automatización o política que configures se puede apuntar a un grupo entero en lugar de endpoint por endpoint.\nAutomatización de scripts # Esta es la parte que más tiempo ahorra en el día a día. Action1 tiene una biblioteca de scripts predefinidos (PowerShell y batch) para las tareas más comunes, pero lo interesante es crear los tuyos y programarlos sobre grupos de endpoints.\nEl flujo de trabajo # Creas o importas un script en la biblioteca de Action1 (.ps1 o .cmd) Lo asignas a un grupo de endpoints o a máquinas concretas Eliges si se ejecuta inmediatamente, a una hora fija o de forma recurrente (diario, semanal\u0026hellip;) Los resultados —salida estándar y código de error— quedan registrados en el historial por endpoint Parámetros dinámicos # Cada script puede declarar variables que se rellenan desde el panel antes de lanzarlo. Así reutilizas el mismo .ps1 para distintos grupos sin duplicar código: por ejemplo, el mismo script de limpieza con una ruta de destino diferente por cliente o sede.\nPermisos de ejecución # El agente de Action1 corre como servicio de sistema (SYSTEM), así que los scripts tienen privilegios elevados por defecto sin necesitar UAC ni credenciales adicionales. En entornos con ExecutionPolicy: Restricted, Action1 invoca PowerShell con -ExecutionPolicy Bypass internamente, de forma que no necesitas tocar las GPOs del dominio para que funcione.\nEjecución offline-safe # Si el endpoint está apagado o sin conexión cuando llega la hora programada, el script se encola y se ejecuta en cuanto el agente recupera contacto. El historial registra tanto la hora de programación como la de ejecución real, para que quede trazabilidad completa.\nEjemplos de automatizaciones reales # Limpiar carpetas temporales y papelera en todos los equipos cada domingo a las 2:00 Forzar gpupdate /force en endpoints de dominio tras cambiar una GPO Verificar que un servicio crítico está corriendo y levantarlo si no lo está Desplegar una actualización de software en silencio, sin ventanas ni interrupciones al usuario Recopilar logs de una aplicación y subirlos a una ruta UNC compartida Inventario de hardware y software # Sin ejecutar nada manualmente, Action1 construye automáticamente el inventario completo de cada endpoint en cuanto el agente se conecta. Se actualiza de forma periódica y siempre refleja el estado real de la máquina.\nQué recoge por endpoint # Categoría Datos Hardware CPU (modelo, núcleos, frecuencia), RAM por ranura, discos (modelo, capacidad, espacio libre, tipo SSD/HDD), GPU, placa base, número de serie Red Adaptadores, MACs, IPs, nombre de dominio o workgroup Sistema Versión de Windows, build, fecha de instalación, último arranque Software Nombre, versión, editor y fecha de instalación de cada aplicación Parches Actualizaciones instaladas y pendientes con KB y fecha de publicación Para qué sirve en la práctica # Lo más valioso no es tener los datos sino poder filtrarlos. Puedes hacer consultas como \u0026ldquo;endpoints con menos de 20 GB libres en disco\u0026rdquo; o \u0026ldquo;equipos que aún tienen instalada la versión X de una aplicación\u0026rdquo; y actuar sobre ese grupo directamente desde el mismo panel: lanzar un script de limpieza, programar una desinstalación o exportar el listado.\nEl inventario de software también sirve como auditoría de licencias rápida: ves exactamente qué está instalado en cada máquina sin conectarte de forma remota ni preguntar al usuario.\nEn resumen # Action1 resuelve bien el triángulo habitual de los RMM: visibilidad, control y automatización, sin coste hasta los 100 endpoints. No reemplaza un SIEM ni una solución EDR, pero para el escenario típico de un administrador que gestiona un parque Windows heterogéneo —en una empresa, para varios clientes, o en un homelab con máquinas virtuales— cubre el 80% de las necesidades del día a día sin complejidad de infraestructura.\nLo que más uso: la combinación de grupos + scripts programados. Una vez definidos los grupos por función, las automatizaciones se despliegan en segundos sobre todos los equipos que correspondan, con historial de ejecución y alertas si algo falla.\n","date":"May 24, 2026","externalUrl":null,"permalink":"/posts/action1-rmm-gestion-endpoints-scripts-inventario/","section":"Posts","summary":"Gestionar varios equipos Windows desde un único panel, sin instalar agentes pesados ni pagar licencias de RMM corporativo, es exactamente lo que resuelve Action1. Es gratuito para hasta 100 endpoints, corre en la nube de su proveedor —sin infraestructura propia que mantener— y el agente en el endpoint pesa menos de 5 MB.\nEste artículo no cubre la instalación: eso se hace en diez minutos siguiendo su asistente. Me centro en lo que realmente importa cuando ya lo tienes corriendo: cómo organizar y controlar los endpoints, cómo automatizar tareas repetitivas con scripts y cómo explotar el inventario para saber exactamente qué hay en cada máquina.\n","title":"Action1 RMM: gestiona endpoints remotos, automatiza scripts e inventaría hardware sin coste","type":"posts"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/categories/automatizaci%C3%B3n/","section":"Categories","summary":"","title":"Automatización","type":"categories"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/endpoints/","section":"Tags","summary":"","title":"Endpoints","type":"tags"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/inventario/","section":"Tags","summary":"","title":"Inventario","type":"tags"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/powershell/","section":"Tags","summary":"","title":"Powershell","type":"tags"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/rmm/","section":"Tags","summary":"","title":"Rmm","type":"tags"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/sysadmin/","section":"Tags","summary":"","title":"Sysadmin","type":"tags"},{"content":"","date":"May 24, 2026","externalUrl":null,"permalink":"/tags/windows/","section":"Tags","summary":"","title":"Windows","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/cumplimiento/","section":"Tags","summary":"","title":"Cumplimiento","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/categories/docker/","section":"Categories","summary":"","title":"Docker","type":"categories"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/monitorizaci%C3%B3n/","section":"Tags","summary":"","title":"Monitorización","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/categories/seguridad/","section":"Categories","summary":"","title":"Seguridad","type":"categories"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/seguridad/","section":"Tags","summary":"","title":"Seguridad","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/","section":"Servicios Rogeliowar","summary":"","title":"Servicios Rogeliowar","type":"page"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/siem/","section":"Tags","summary":"","title":"Siem","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"22 May 2026","externalUrl":null,"permalink":"/en/tags/wazuh/","section":"Tags","summary":"","title":"Wazuh","type":"tags"},{"content":" What is ENS and Why It Matters # The National Security Framework (ENS) is the mandatory cybersecurity regulatory framework for Spanish Public Administrations and private companies that provide services to them. It is regulated by the Royal Decree 311/2022 and establishes the principles, requirements, and security measures that must be applied to information systems that handle public data or services.\nIn practice, the ENS classifies systems based on the impact that a security incident would have on the organization and citizens. The level of measures to be implemented depends on that classification.\nThe Three ENS Categories # The ENS defines three categorization levels:\nBasic Category For systems whose compromise would have limited impact. It usually applies to informational websites, low-risk internal services, or systems with non-sensitive data. The required measures are the minimum of the framework.\nMedium Category For systems whose compromise would cause considerable harm to the organization or third parties. It is the most common category in internal management environments: ERP, employee portals, HR systems, document management platforms. It requires active monitoring controls, incident management, and access control.\nHigh Category For critical systems whose compromise could cause serious or very serious harm. It applies to systems that manage critical infrastructure, health data, judicial or defense systems. It requires the strictest measures of the framework.\nMedium Category in Practice # A system classified as medium category requires, among other controls:\nContinuous monitoring of security events Active detection and incident management Privileged access control and action traceability System file integrity Centralized log recording and analysis This is exactly where Wazuh comes in.\nWazuh as a Compliance Platform # Wazuh is an open source SIEM (Security Information and Event Management) platform that natively covers most of the monitoring controls required by medium category: real-time log analysis, intrusion detection, file integrity monitoring, software inventory, and active incident response.\nIn this article I deploy a single node with Docker, generate the necessary TLS certificates, and add custom rules oriented to the most common security controls in medium category environments.\nStack Architecture # The single-node Wazuh stack has three components:\nwazuh.manager — event analysis and correlation engine wazuh.indexer — OpenSearch-based storage wazuh.dashboard — web interface (OpenSearch Dashboards) Communication between components uses mutual TLS with custom certificates, which we generate before the first startup.\nProject Structure # wazuh/ ├── docker-compose.yml ├── generate-certs.yml ├── gen-certs.sh ├── deploy.sh ├── .env └── config/ ├── certs.yml ├── wazuh_manager/ │ ├── wazuh_manager.conf │ └── local_rules.xml ├── wazuh_indexer/ │ ├── wazuh.indexer.yml │ └── internal_users.yml └── wazuh_dashboard/ ├── opensearch_dashboards.yml └── wazuh.yml Environment Variables # Create the .env file from the example:\ncp .env.example .env Minimum content:\nINDEXER_ADMIN_PASSWORD=TU_PASSWORD_SEGURO KIBANA_PASSWORD=TU_PASSWORD_KIBANA API_PASSWORD=TU_PASSWORD_API SMTP_HOST=mail.tudominio.com SMTP_FROM=alertas@tudominio.com SMTP_TO=admin@tudominio.com Use passwords of at least 12 characters with uppercase letters, numbers, and symbols. The indexer validates them at startup.\nGenerate TLS Certificates # Wazuh requires TLS certificates for internal communication between manager, indexer, and dashboard. The stack includes a generator container:\ndocker compose -f generate-certs.yml run --rm generator This creates config/wazuh_indexer_ssl_certs/ with:\nroot-ca.pem — self-signed root CA wazuh.manager.pem / wazuh.manager-key.pem wazuh.indexer.pem / wazuh.indexer-key.pem wazuh.dashboard.pem / wazuh.dashboard-key.pem docker-compose.yml # services: wazuh.manager: image: wazuh/wazuh-manager:4.9.2 hostname: wazuh.manager restart: always ulimits: memlock: soft: -1 hard: -1 nofile: soft: 655360 hard: 655360 ports: - \u0026#34;1514:1514/tcp\u0026#34; # agentes TCP - \u0026#34;1515:1515/tcp\u0026#34; # enrollment - \u0026#34;5140:5140/udp\u0026#34; # syslog externo environment: INDEXER_URL: https://wazuh.indexer:9200 INDEXER_USERNAME: admin INDEXER_PASSWORD: ${INDEXER_ADMIN_PASSWORD} FILEBEAT_SSL_VERIFICATION_MODE: full SSL_CERTIFICATE_AUTHORITIES: /etc/ssl/root-ca.pem SSL_CERTIFICATE: /etc/ssl/filebeat.pem SSL_KEY: /etc/ssl/filebeat.key API_USERNAME: wazuh-wui API_PASSWORD: ${API_PASSWORD} volumes: - wazuh_api_configuration:/var/ossec/api/configuration - wazuh_etc:/var/ossec/etc - wazuh_logs:/var/ossec/logs - ./config/wazuh_indexer_ssl_certs/root-ca.pem:/etc/ssl/root-ca.pem - ./config/wazuh_indexer_ssl_certs/wazuh.manager.pem:/etc/ssl/filebeat.pem - ./config/wazuh_indexer_ssl_certs/wazuh.manager-key.pem:/etc/ssl/filebeat.key - ./config/wazuh_manager/local_rules.xml:/var/ossec/etc/rules/local_rules.xml - ./config/wazuh_manager/wazuh_manager.conf:/wazuh-config-mount/etc/ossec.conf wazuh.indexer: image: wazuh/wazuh-indexer:4.9.2 hostname: wazuh.indexer restart: always ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 environment: OPENSEARCH_JAVA_OPTS: \u0026#34;-Xms1g -Xmx1g\u0026#34; volumes: - wazuh-indexer-data:/var/lib/wazuh-indexer - ./config/wazuh_indexer/wazuh.indexer.yml:/usr/share/wazuh-indexer/opensearch.yml - ./config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-indexer/certs/root-ca.pem - ./config/wazuh_indexer_ssl_certs/wazuh.indexer.pem:/usr/share/wazuh-indexer/certs/wazuh.indexer.pem - ./config/wazuh_indexer_ssl_certs/wazuh.indexer-key.pem:/usr/share/wazuh-indexer/certs/wazuh.indexer-key.pem wazuh.dashboard: image: wazuh/wazuh-dashboard:4.9.2 hostname: wazuh.dashboard restart: always ports: - \u0026#34;8443:5601\u0026#34; environment: INDEXER_USERNAME: admin INDEXER_PASSWORD: ${INDEXER_ADMIN_PASSWORD} WAZUH_API_URL: https://wazuh.manager DASHBOARD_USERNAME: kibanaserver DASHBOARD_PASSWORD: ${KIBANA_PASSWORD} API_USERNAME: wazuh-wui API_PASSWORD: ${API_PASSWORD} volumes: - ./config/wazuh_dashboard/opensearch_dashboards.yml:/usr/share/wazuh-dashboard/config/opensearch_dashboards.yml - ./config/wazuh_dashboard/wazuh.yml:/usr/share/wazuh-dashboard/data/wazuh/config/wazuh.yml - ./config/wazuh_indexer_ssl_certs/wazuh.dashboard.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem - ./config/wazuh_indexer_ssl_certs/wazuh.dashboard-key.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem - ./config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-dashboard/certs/root-ca.pem depends_on: - wazuh.indexer volumes: wazuh_api_configuration: wazuh_etc: wazuh_logs: wazuh_queue: wazuh_var_multigroups: wazuh_integrations: wazuh_active_response: wazuh_agentless: wazuh_wodles: filebeat_etc: filebeat_var: wazuh-indexer-data: Deployment script # #!/usr/bin/env bash set -euo pipefail echo \u0026#34;=== [1/4] Generando certificados SSL ===\u0026#34; docker compose -f generate-certs.yml run --rm generator echo \u0026#34;=== [2/4] Abriendo puertos en UFW ===\u0026#34; ufw allow 1514/tcp comment \u0026#34;Wazuh agentes TCP\u0026#34; ufw allow 1515/tcp comment \u0026#34;Wazuh enrollment\u0026#34; ufw allow 5140/udp comment \u0026#34;Wazuh syslog\u0026#34; echo \u0026#34;=== [3/4] Iniciando stack ===\u0026#34; docker compose up -d echo \u0026#34;=== [4/4] Esperando indexer (2-3 min) ===\u0026#34; for i in $(seq 1 30); do if docker compose exec wazuh.indexer \\ curl -ks https://localhost:9200/_cat/health 2\u0026gt;/dev/null | grep -qE \u0026#39;green|yellow\u0026#39;; then echo \u0026#34;Indexer OK\u0026#34; break fi echo \u0026#34;Esperando... ($i/30)\u0026#34; sleep 10 done docker compose ps echo \u0026#34;Dashboard disponible en https://TU-IP:8443\u0026#34; Manager configuration # The wazuh_manager.conf file defines global behavior. Key points:\n\u0026lt;ossec_config\u0026gt; \u0026lt;global\u0026gt; \u0026lt;jsonout_output\u0026gt;yes\u0026lt;/jsonout_output\u0026gt; \u0026lt;alerts_log\u0026gt;yes\u0026lt;/alerts_log\u0026gt; \u0026lt;email_notification\u0026gt;no\u0026lt;/email_notification\u0026gt; \u0026lt;smtp_server\u0026gt;mail.tudominio.com\u0026lt;/smtp_server\u0026gt; \u0026lt;email_from\u0026gt;alertas@tudominio.com\u0026lt;/email_from\u0026gt; \u0026lt;email_to\u0026gt;admin@tudominio.com\u0026lt;/email_to\u0026gt; \u0026lt;email_maxperhour\u0026gt;12\u0026lt;/email_maxperhour\u0026gt; \u0026lt;agents_disconnection_time\u0026gt;10m\u0026lt;/agents_disconnection_time\u0026gt; \u0026lt;white_list\u0026gt;127.0.0.1\u0026lt;/white_list\u0026gt; \u0026lt;white_list\u0026gt;192.168.0.0/16\u0026lt;/white_list\u0026gt; \u0026lt;/global\u0026gt; \u0026lt;alerts\u0026gt; \u0026lt;log_alert_level\u0026gt;3\u0026lt;/log_alert_level\u0026gt; \u0026lt;email_alert_level\u0026gt;12\u0026lt;/email_alert_level\u0026gt; \u0026lt;/alerts\u0026gt; \u0026lt;!-- Agentes via TCP --\u0026gt; \u0026lt;remote\u0026gt; \u0026lt;connection\u0026gt;secure\u0026lt;/connection\u0026gt; \u0026lt;port\u0026gt;1514\u0026lt;/port\u0026gt; \u0026lt;protocol\u0026gt;tcp\u0026lt;/protocol\u0026gt; \u0026lt;queue_size\u0026gt;131072\u0026lt;/queue_size\u0026gt; \u0026lt;/remote\u0026gt; \u0026lt;!-- Syslog UDP para firewalls y dispositivos de red --\u0026gt; \u0026lt;remote\u0026gt; \u0026lt;connection\u0026gt;syslog\u0026lt;/connection\u0026gt; \u0026lt;port\u0026gt;5140\u0026lt;/port\u0026gt; \u0026lt;protocol\u0026gt;udp\u0026lt;/protocol\u0026gt; \u0026lt;allowed-ips\u0026gt;0.0.0.0/0\u0026lt;/allowed-ips\u0026gt; \u0026lt;/remote\u0026gt; \u0026lt;syscheck\u0026gt; \u0026lt;disabled\u0026gt;no\u0026lt;/disabled\u0026gt; \u0026lt;frequency\u0026gt;43200\u0026lt;/frequency\u0026gt; \u0026lt;scan_on_start\u0026gt;yes\u0026lt;/scan_on_start\u0026gt; \u0026lt;alert_new_files\u0026gt;yes\u0026lt;/alert_new_files\u0026gt; \u0026lt;directories check_all=\u0026#34;yes\u0026#34; report_changes=\u0026#34;yes\u0026#34;\u0026gt;/etc,/usr/bin,/usr/sbin\u0026lt;/directories\u0026gt; \u0026lt;directories check_all=\u0026#34;yes\u0026#34;\u0026gt;/bin,/sbin,/boot\u0026lt;/directories\u0026gt; \u0026lt;/syscheck\u0026gt; \u0026lt;/ossec_config\u0026gt; Custom security rules # Wazuh includes thousands of predefined rules. Custom ones go in local_rules.xml with IDs from 100000 onwards. This block implements the most common monitoring controls in environments with medium-category compliance requirements: attack detection, privileged access control, file integrity, and service availability.\n\u0026lt;group name=\u0026#34;cumplimiento_custom,\u0026#34;\u0026gt; \u0026lt;!-- DETECCIÓN DE ATAQUES — autenticación --\u0026gt; \u0026lt;!-- Fuerza bruta SSH: 8 intentos fallidos en 120 segundos --\u0026gt; \u0026lt;rule id=\u0026#34;100001\u0026#34; level=\u0026#34;10\u0026#34; frequency=\u0026#34;8\u0026#34; timeframe=\u0026#34;120\u0026#34;\u0026gt; \u0026lt;if_matched_sid\u0026gt;5760\u0026lt;/if_matched_sid\u0026gt; \u0026lt;description\u0026gt;Posible ataque de fuerza bruta SSH — $(attempts) intentos fallidos\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;authentication_failures,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;!-- Cuenta de usuario bloqueada --\u0026gt; \u0026lt;rule id=\u0026#34;100002\u0026#34; level=\u0026#34;10\u0026#34;\u0026gt; \u0026lt;if_sid\u0026gt;5503\u0026lt;/if_sid\u0026gt; \u0026lt;description\u0026gt;Cuenta bloqueada por múltiples intentos fallidos de autenticación\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;authentication_failures,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;!-- CONTROL DE ACCESO PRIVILEGIADO --\u0026gt; \u0026lt;!-- Escalada de privilegios con sudo --\u0026gt; \u0026lt;rule id=\u0026#34;100003\u0026#34; level=\u0026#34;9\u0026#34;\u0026gt; \u0026lt;if_sid\u0026gt;5402\u0026lt;/if_sid\u0026gt; \u0026lt;description\u0026gt;Escalada de privilegios detectada mediante sudo\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;priv_escalation,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;!-- Nuevo usuario creado en el sistema --\u0026gt; \u0026lt;rule id=\u0026#34;100010\u0026#34; level=\u0026#34;8\u0026#34;\u0026gt; \u0026lt;if_sid\u0026gt;5902\u0026lt;/if_sid\u0026gt; \u0026lt;description\u0026gt;Nuevo usuario creado en el sistema — revisión recomendada\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;account_changes,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;!-- INTEGRIDAD DE FICHEROS --\u0026gt; \u0026lt;!-- Modificación de fichero crítico del sistema --\u0026gt; \u0026lt;rule id=\u0026#34;100020\u0026#34; level=\u0026#34;10\u0026#34;\u0026gt; \u0026lt;if_sid\u0026gt;550\u0026lt;/if_sid\u0026gt; \u0026lt;description\u0026gt;Fichero crítico del sistema modificado — $(file)\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;syscheck,integrity_check_host,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;!-- DISPONIBILIDAD DE SERVICIOS --\u0026gt; \u0026lt;!-- Servicio detenido inesperadamente --\u0026gt; \u0026lt;rule id=\u0026#34;100030\u0026#34; level=\u0026#34;8\u0026#34;\u0026gt; \u0026lt;if_sid\u0026gt;2904\u0026lt;/if_sid\u0026gt; \u0026lt;description\u0026gt;Servicio detenido de forma inesperada — $(service)\u0026lt;/description\u0026gt; \u0026lt;group\u0026gt;service_control,\u0026lt;/group\u0026gt; \u0026lt;/rule\u0026gt; \u0026lt;/group\u0026gt; What each block covers # Rules Control area Description 100001–100002 Attack detection Brute force and account lockout 100003, 100010 Privileged access Sudo and user creation 100020 System integrity Changes to critical files 100030 Availability Unexpected service failures The alert levels (8-10) determine which alerts generate email notifications based on the threshold configured in email_alert_level.\nEnroll a Linux agent # From the server where the agent will be installed:\n# Descargar e instalar (ajusta versión y arquitectura) wget https://packages.wazuh.com/4.x/apt/pool/main/w/wazuh-agent/wazuh-agent_4.9.2-1_amd64.deb dpkg -i wazuh-agent_4.9.2-1_amd64.deb # Apuntar al manager sed -i \u0026#39;s/MANAGER_IP/192.168.X.X/\u0026#39; /var/ossec/etc/ossec.conf # Registrar y arrancar /var/ossec/bin/agent-auth -m 192.168.X.X systemctl enable wazuh-agent \u0026amp;\u0026amp; systemctl start wazuh-agent Dashboard access # Once the stack is UP (the indexer takes 2-3 minutes to initialize):\nhttps://TU-IP:8443 Usuario: admin Contraseña: (la definida en INDEXER_ADMIN_PASSWORD) Important: change all default passwords before exposing the service to the network.\nConclusion # With this stack you have a complete SIEM on a single server capable of covering the monitoring controls required by the most common compliance frameworks. The natural next step is to add more agents, configure email or webhook alerts for high-level events, and review the compliance dashboards that Wazuh includes out of the box for PCI-DSS, GDPR, HIPAA, and ENS itself. To verify the level of compliance of a system with ENS, CCN-CERT publishes the security guides (CCN-STIC series) as reference.\n","date":"22 May 2026","externalUrl":null,"permalink":"/en/posts/wazuh-siem-docker-ssl-cumplimiento/","section":"Posts","summary":"What is ENS and Why It Matters # The National Security Framework (ENS) is the mandatory cybersecurity regulatory framework for Spanish Public Administrations and private companies that provide services to them. It is regulated by the Royal Decree 311/2022 and establishes the principles, requirements, and security measures that must be applied to information systems that handle public data or services.\n","title":"Wazuh SIEM with Docker: complete deployment with SSL and compliance rules","type":"posts"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/bases-de-datos/","section":"Tags","summary":"","title":"Bases-De-Datos","type":"tags"},{"content":" The Problem # I needed to access my home server via SSH from my mobile phone without exposing it directly to the internet. The obvious options were bad: opening port 22 to the world is suicidal, and trusting third-party apps with root access didn\u0026rsquo;t convince me. The solution that worked: WireGuard + Termius.\nWhy This Combination # WireGuard is lightweight, fast, and consumes little battery on mobile devices. Termius is a polished SSH client that handles private keys well. Together, you have secure access without complications.\nStep 1: WireGuard Installation and Configuration on the Server # I installed WireGuard on my server (Debian 12):\nsudo apt update sudo apt install wireguard wireguard-tools I generated the server\u0026rsquo;s public and private keys:\ncd /etc/wireguard sudo wg genkey | tee privatekey | wg pubkey \u0026gt; publickey I created the /etc/wireguard/wg0.conf configuration file:\n[Interface] Address = 10.0.0.1/24 ListenPort = 51820 PrivateKey = [CONTENIDO DE privatekey] PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE [Peer] PublicKey = [CLAVE PÚBLICA DEL MÓVIL - GENERARLA DESPUÉS] AllowedIPs = 10.0.0.2/32 I activated the service:\nsudo systemctl enable wg-quick@wg0 sudo systemctl start wg-quick@wg0 I opened UDP port 51820 in the firewall (in my case, the router):\nsudo ufw allow 51820/udp Step 2: Client Configuration on Mobile # I installed WireGuard from the Play Store (Android) or App Store (iOS).\nI generated the mobile\u0026rsquo;s keys on the server:\nwg genkey | tee mobile_privatekey | wg pubkey \u0026gt; mobile_publickey I created the configuration file for the mobile:\n[Interface] Address = 10.0.0.2/24 PrivateKey = [CONTENIDO DE mobile_privatekey] DNS = 8.8.8.8 [Peer] PublicKey = [CLAVE PÚBLICA DEL SERVIDOR] Endpoint = [IP_PÚBLICA_SERVIDOR]:51820 AllowedIPs = 10.0.0.0/24 PersistentKeepalive = 25 I exported this file as a QR code or transferred it via USB to the mobile. WireGuard imports it directly.\nI activated the connection in WireGuard on the mobile and verified connectivity:\nwg show Step 3: SSH Configuration in Termius # In Termius I created a new connection:\nHost: 10.0.0.1 (the server\u0026rsquo;s internal IP in WireGuard) Port: 22 (standard SSH, doesn\u0026rsquo;t need to be open to the outside) User: my regular username Authentication: SSH private key I imported my SSH private key from the mobile\u0026rsquo;s files. Termius handles it without exposing files.\nStep 4: Testing and Adjustments # I connected to WireGuard from the mobile. I opened Termius and connected to the server. It worked on the first try.\nThe latency is imperceptible. WireGuard\u0026rsquo;s battery consumption is minimal (barely 2-3% in 8 hours standby).\nSecurity Details That Matter # The SSH server is never exposed to the internet WireGuard uses modern cryptography (Noise protocol) Private keys never travel over the network SSH traffic within the tunnel is doubly encrypted What I Would Change # Nothing. This setup has been running flawlessly for months. The only improvement would be using dynamic DNS addresses if my public IP changes, but that\u0026rsquo;s another article.\nUpdate: This same method works for connecting other devices (laptop, tablet). Just generate new keys and add more peers in WireGuard.\nRecommended Equipment # TECLAST T65 Tablet 13.4\u0026quot; Android 16 with Keyboard and Stylus — Tablet with 4G LTE as a portable SSH/VPN client from anywhere GL.iNet MT3000 Router — Router with integrated WireGuard to set up the VPN tunnel in minutes Foldable Aluminum Laptop Stand with Adjustable Angle — Essential ergonomics if you use a tablet or laptop to manage your server Affiliate links. No extra cost to you.\n","date":"19 May 2026","externalUrl":null,"permalink":"/en/posts/conectar-el-movil-a-ssh-desde-cualquier-lugar-con-wireguard-y-termius/","section":"Posts","summary":"The Problem # I needed to access my home server via SSH from my mobile phone without exposing it directly to the internet. The obvious options were bad: opening port 22 to the world is suicidal, and trusting third-party apps with root access didn’t convince me. The solution that worked: WireGuard + Termius.\nWhy This Combination # WireGuard is lightweight, fast, and consumes little battery on mobile devices. Termius is a polished SSH client that handles private keys well. Together, you have secure access without complications.\n","title":"Connect your mobile to SSH from anywhere with WireGuard and Termius","type":"posts"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/credenciales/","section":"Tags","summary":"","title":"Credenciales","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/grafana/","section":"Tags","summary":"","title":"Grafana","type":"tags"},{"content":" The Real Problem # I recently realized I had pushed a .env file with API credentials to a private repository. Even though it was private, that\u0026rsquo;s no excuse. A compromised access, a repository that becomes public, or simply a security audit would have exposed my tokens. I learned that I can\u0026rsquo;t rely on deleting files in subsequent commits—Git keeps all the history.\nWhy git-filter-repo # Years ago I would have used git filter-branch, but it\u0026rsquo;s slow and error-prone. git-filter-repo is the modern tool recommended by Git maintainers. It\u0026rsquo;s fast, precise, and has better options for this job.\nInstallation # On my Debian server:\napt-get install git-filter-repo Or with pip:\npip3 install git-filter-repo Step 1: Identify the Damage # First, I need to know which commits contain credentials. I search for suspicious patterns:\ngit log --all --oneline | head -20 git log -p --all | grep -i \u0026#34;token\\|password\\|api_key\u0026#34; | head -10 I also review what sensitive files are in the history:\ngit log --all --full-history -- \u0026#34;.env\u0026#34; git log --all --full-history -- \u0026#34;config.yml\u0026#34; In my case, I found that .env had been committed 3 times and a credentials.json file in 2.\nStep 2: Make a Backup # I never do this without a backup:\ncd /path/to/my/repo git clone --mirror . backup-mirror.git If something goes wrong, I have a complete copy of the repository with all its history.\nStep 3: Clean Specific Files # I run git-filter-repo to remove the sensitive files from the entire history:\ngit-filter-repo --invert-paths --path .env --path credentials.json The --invert-paths parameter means it keeps everything EXCEPT what I specify. This is the opposite of what it seems, but it works perfectly.\nThe process takes a few seconds and rewrites all the history. At the end, I see:\nProcessed 47 commits New history has 47 commits Step 4: Force Push (Carefully) # Since I\u0026rsquo;ve rewritten the history, I need to do a force push. On a home server where I\u0026rsquo;m the only dev, it\u0026rsquo;s safe:\ngit push origin --force --all git push origin --force --tags If it\u0026rsquo;s a shared repository, I coordinate with the team so everyone does git reset --hard origin/main afterward.\nStep 5: Rotate Compromised Credentials # The credentials that were in Git are no longer there, but I must assume they were compromised. I rotate all tokens:\nAPI Keys: I revoke the old ones in the API panel and generate new ones Passwords: I change the password for any service that used those credentials Database Tokens: I regenerate database credentials SSH Keys: If they were exposed, I generate new pairs I document these changes in a private file (not in Git):\n2026-05-19 - Rotación de credenciales post-exposición - API key antigua: revocada, nueva generada - Token DB: regenerado - Contraseña servicio X: cambiada Step 6: Future Prevention # I add rules to .gitignore (now it\u0026rsquo;s clean):\n.env .env.local credentials.json secrets/ I also configure a pre-commit hook to detect dangerous patterns:\ngit config core.hooksPath .githooks And I create .githooks/pre-commit:\n#!/bin/bash if git diff --cached | grep -iE \u0026#34;(password|token|api_key|secret)\u0026#34; \u0026amp;\u0026amp; \\ ! git diff --cached | grep \u0026#34;.gitignore\u0026#34;; then echo \u0026#34;⚠️ Posible credencial detectada. Abortando.\u0026#34; exit 1 fi Conclusion # The cleanup takes 10 minutes. Credential rotation, another while. But it\u0026rsquo;s time well spent. On a home server, I have no excuse for being negligent with secrets. Now I use environment variables and local files that never go into Git.\nLesson learned: Secrets never go in version control, not even \u0026ldquo;private\u0026rdquo;. Period.\nRecommended Equipment # YubiKey 5 NFC — Physical security key for SSH and GitLab — eliminates the risk of stolen tokens 2TB External Hard Drive — Backup repositories before destructive operations like git-filter-repo Affiliate links. No extra cost to you.\n","date":"19 May 2026","externalUrl":null,"permalink":"/en/posts/como-limpiar-credenciales-expuestas-en-git-con-git-filter-repo-y-rotar-tokens/","section":"Posts","summary":"The Real Problem # I recently realized I had pushed a .env file with API credentials to a private repository. Even though it was private, that’s no excuse. A compromised access, a repository that becomes public, or simply a security audit would have exposed my tokens. I learned that I can’t rely on deleting files in subsequent commits—Git keeps all the history.\n","title":"How to Clean Up Exposed Credentials in Git with git-filter-repo and Rotate Tokens","type":"posts"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/listmonk/","section":"Tags","summary":"","title":"Listmonk","type":"tags"},{"content":" The initial problem # I had Grafana and Listmonk running in Docker containers, each with its own embedded PostgreSQL instance. This worked, but it was inefficient: two database engines consuming resources and no centralized way to do backups. I decided to consolidate everything into a single shared PostgreSQL instance.\nPreparation: setting up central PostgreSQL # The first step was to create the PostgreSQL server that would be the central point. I did this with a dedicated docker-compose:\nversion: \u0026#39;3.8\u0026#39; services: postgres: image: postgres:15-alpine container_name: postgres-central environment: POSTGRES_USER: admin POSTGRES_PASSWORD: tu_contraseña_segura POSTGRES_DB: default_db ports: - \u0026#34;5432:5432\u0026#34; volumes: - postgres_data:/var/lib/postgresql/data networks: - central-net volumes: postgres_data: networks: central-net: driver: bridge I ran docker-compose up -d and verified it worked with docker exec postgres-central psql -U admin -d default_db -c \u0026quot;\\l\u0026quot;.\nCreate databases for each service # I accessed the PostgreSQL container and created the databases I would need:\ndocker exec -it postgres-central psql -U admin -d default_db Once inside:\nCREATE DATABASE grafana; CREATE DATABASE listmonk; CREATE USER grafana_user WITH PASSWORD \u0026#39;grafana_pass\u0026#39;; CREATE USER listmonk_user WITH PASSWORD \u0026#39;listmonk_pass\u0026#39;; GRANT ALL PRIVILEGES ON DATABASE grafana TO grafana_user; GRANT ALL PRIVILEGES ON DATABASE listmonk TO listmonk_user; Export data from the old databases # Before moving anything, I dumped the existing databases. For Grafana:\ndocker exec grafana-container pg_dump -U grafana -d grafana \u0026gt; grafana_backup.sql For Listmonk:\ndocker exec listmonk-container pg_dump -U listmonk -d listmonk \u0026gt; listmonk_backup.sql Import data into central PostgreSQL # I imported the backups into the new databases:\ndocker exec -i postgres-central psql -U grafana_user -d grafana \u0026lt; grafana_backup.sql docker exec -i postgres-central psql -U listmonk_user -d listmonk \u0026lt; listmonk_backup.sql I verified that the tables were present with \\dt in each database.\nUpdate Grafana # I modified Grafana\u0026rsquo;s docker-compose to point to central PostgreSQL:\nservices: grafana: image: grafana/grafana:latest container_name: grafana environment: GF_DATABASE_TYPE: postgres GF_DATABASE_HOST: postgres-central:5432 GF_DATABASE_NAME: grafana GF_DATABASE_USER: grafana_user GF_DATABASE_PASSWORD: grafana_pass networks: - central-net - grafana-net networks: central-net: external: true grafana-net: driver: bridge Important: the central-net network must be external: true because it already exists in the PostgreSQL docker-compose.\nUpdate Listmonk # The same for Listmonk. Its config in docker-compose:\nservices: listmonk: image: listmonk/listmonk:latest container_name: listmonk environment: LISTMONK_db__host: postgres-central LISTMONK_db__port: \u0026#34;5432\u0026#34; LISTMONK_db__user: listmonk_user LISTMONK_db__password: listmonk_pass LISTMONK_db__database: listmonk networks: - central-net - listmonk-net networks: central-net: external: true listmonk-net: driver: bridge Testing and cleanup # I brought up both containers: docker-compose up -d. I verified that Grafana and Listmonk started correctly and that their data was intact.\nOnce everything was confirmed working, I deleted the volumes from the old databases:\ndocker volume rm grafana_postgres_data listmonk_postgres_data Real benefits # Now I have a single backup point, less RAM consumption, and can scale more easily. One thing to watch: make sure central PostgreSQL is on the correct network or that services can communicate via external host.\nThe migration took me an hour. No stress.\nRecommended equipment # 1TB NVMe SSD — Improves database server performance Intel N100 Mini PC — Silent and efficient home server for running Docker and PostgreSQL 24/7 600VA UPS/Power Backup — Protects the server and database from power outages Affiliate links. No extra cost to you.\n","date":"19 May 2026","externalUrl":null,"permalink":"/en/posts/migrar-grafana-y-listmonk-a-postgresql-centralizado-en-docker/","section":"Posts","summary":"The initial problem # I had Grafana and Listmonk running in Docker containers, each with its own embedded PostgreSQL instance. This worked, but it was inefficient: two database engines consuming resources and no centralized way to do backups. I decided to consolidate everything into a single shared PostgreSQL instance.\nPreparation: setting up central PostgreSQL # The first step was to create the PostgreSQL server that would be the central point. I did this with a dedicated docker-compose:\n","title":"Migrate Grafana and Listmonk to centralized PostgreSQL in Docker","type":"posts"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/m%C3%B3vil/","section":"Tags","summary":"","title":"Móvil","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/postgresql/","section":"Tags","summary":"","title":"Postgresql","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/ssh/","section":"Tags","summary":"","title":"Ssh","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/vpn/","section":"Tags","summary":"","title":"Vpn","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/en/tags/wireguard/","section":"Tags","summary":"","title":"Wireguard","type":"tags"},{"content":"","date":"11 May 2026","externalUrl":null,"permalink":"/en/tags/automatizaci%C3%B3n/","section":"Tags","summary":"","title":"Automatización","type":"tags"},{"content":"","date":"11 May 2026","externalUrl":null,"permalink":"/en/tags/backups/","section":"Tags","summary":"","title":"Backups","type":"tags"},{"content":"","date":"11 May 2026","externalUrl":null,"permalink":"/en/categories/infraestructura/","section":"Categories","summary":"","title":"Infraestructura","type":"categories"},{"content":"","date":"11 May 2026","externalUrl":null,"permalink":"/en/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"11 May 2026","externalUrl":null,"permalink":"/en/tags/restic/","section":"Tags","summary":"","title":"Restic","type":"tags"},{"content":"I already had backups with rsync and cron, but rsync copies files, not snapshots. If you accidentally delete a file and the backup syncs before you notice, you lose it. Restic solves that and adds something rsync will never provide: AES-256 encryption, deduplication, and snapshots with navigable history.\nWhat makes Restic different # Feature rsync Restic AES-256 encryption No Yes Deduplication No Yes (block-level) Navigable snapshots No Yes Multiple backends No SFTP, S3, Backblaze, rclone… Integrity checking No restic check Retention policy Manual restic forget --prune Deduplication is especially useful for database backups and configuration directories that change little: a Restic repository with 6 months of daily backups usually takes up much less space than 180 full copies.\nInstallation # On Ubuntu/Debian, the official repository version is usually outdated. Better to download the binary directly from the official GitHub releases:\n# Descarga la última versión (ajusta la versión si hay una más reciente) wget https://github.com/restic/restic/releases/download/v0.17.3/restic_0.17.3_linux_amd64.bz2 bunzip2 restic_0.17.3_linux_amd64.bz2 chmod +x restic_0.17.3_linux_amd64 sudo mv restic_0.17.3_linux_amd64 /usr/local/bin/restic # Verificar restic version Or with system repositories if you don\u0026rsquo;t mind the version:\nsudo apt install restic # Debian/Ubuntu sudo dnf install restic # Fedora/RHEL Key concepts before you start # Repository: the destination where Restic stores the backups. It can be a local folder, an SFTP server, an S3 bucket, etc. Snapshot: every time you run restic backup, Restic saves a point-in-time snapshot. Snapshots share deduplicated blocks, so they don\u0026rsquo;t multiply the space used. Password: the repository is encrypted with a password. Without it, the data is unreadable. Store it in a password manager or in a file separate from the backup. Initialize a repository # Option A: local repository # restic init --repo /opt/backups/mi-servidor Option B: repository on remote server via SFTP # restic init --repo sftp:usuario@servidor-backup:/opt/restic/mi-servidor Option C: repository on S3 (or compatible: Backblaze B2, MinIO…) # export AWS_ACCESS_KEY_ID=\u0026#34;TU_ACCESS_KEY_AQUI\u0026#34; export AWS_SECRET_ACCESS_KEY=\u0026#34;TU_SECRET_KEY_AQUI\u0026#34; restic init --repo s3:s3.eu-west-1.amazonaws.com/mi-bucket-backups/mi-servidor In all three cases, Restic will ask you for a password to encrypt the repository. Guard it well — without it you can\u0026rsquo;t restore anything.\nEnvironment variables file # To avoid typing the password with every Restic command, create a variables file:\n# /etc/restic/env.sh (solo root puede leerlo) sudo mkdir -p /etc/restic sudo chmod 700 /etc/restic sudo tee /etc/restic/env.sh \u0026gt; /dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; export RESTIC_REPOSITORY=\u0026#34;/opt/backups/mi-servidor\u0026#34; export RESTIC_PASSWORD=\u0026#34;TU_CONTRASENA_AQUI\u0026#34; EOF sudo chmod 600 /etc/restic/env.sh From now on, before any Restic command:\nsource /etc/restic/env.sh Or with environment variables inline for scripts:\nenv $(cat /etc/restic/env.sh | grep export | sed \u0026#39;s/export //\u0026#39;) restic snapshots First backup # source /etc/restic/env.sh # Backup de los datos de Docker y configuraciones restic backup \\ /opt/app/ \\ /etc/nginx/ \\ /home/usuario/ \\ --exclude=\u0026#39;/opt/app/logs\u0026#39; \\ --exclude=\u0026#39;*.tmp\u0026#39; \\ --tag servidor-web The output shows how many files it processed, how many are new, and the space saved by deduplication:\nFiles: 1234 new, 567 changed, 8901 unmodified Dirs: 45 new, 12 changed, 89 unmodified Added to the repo: 234.567 MiB processed 10702 files, 1.234 GiB in 0:23 snapshot a1b2c3d4 saved View and navigate snapshots # # Listar todos los snapshots restic snapshots # Listar ficheros dentro de un snapshot específico restic ls a1b2c3d4 # Buscar un fichero en todos los snapshots restic find --tag servidor-web nombre-fichero.conf Output of restic snapshots:\nID Time Host Tags Paths ──────────────────────────────────────────────────────────────────── a1b2c3d4 2026-05-11 03:00:02 mi-servidor servidor-web /opt/app, /etc/nginx, /home/usuario e5f6a7b8 2026-05-10 03:00:01 mi-servidor servidor-web /opt/app, /etc/nginx, /home/usuario Restore data # Complete restoration of a snapshot # # Restaura todo en /tmp/restauracion para revisar antes de mover restic restore a1b2c3d4 --target /tmp/restauracion Restore only a file or directory # # Restaura solo nginx.conf del snapshot más reciente restic restore latest --target /tmp/restauracion \\ --include /etc/nginx/nginx.conf Mount the repository as a filesystem (useful for exploring) # # Requiere FUSE instalado: sudo apt install fuse mkdir -p /mnt/restic-backups restic mount /mnt/restic-backups \u0026amp; # Ahora puedes navegar por todos los snapshots ls /mnt/restic-backups/snapshots/ # → a1b2c3d4/ e5f6a7b8/ latest/ # Cuando acabes fusermount -u /mnt/restic-backups Automatic retention policy # Without a retention policy, the repository grows indefinitely. This is the configuration I use for daily backups:\n# 7 días diarios, 4 semanas, 6 meses, 1 año restic forget \\ --keep-daily 7 \\ --keep-weekly 4 \\ --keep-monthly 6 \\ --keep-yearly 1 \\ --tag servidor-web \\ --prune --prune physically removes unreferenced data. Without it, forget only removes the snapshot metadata but doesn\u0026rsquo;t free up space.\nVerify repository integrity # # Verificación rápida de metadatos restic check # Verificación completa leyendo todos los datos (lento, hazlo mensualmente) restic check --read-data If restic check fails, the repository has corruption. That\u0026rsquo;s why it\u0026rsquo;s always recommended to have at least two repositories in different destinations (the well-known 3-2-1 rule).\nAutomate with systemd timer # Restic integrates perfectly with systemd timers, which allow capturing output in journald and executing the task even if the server was powered off at the scheduled time.\n/etc/systemd/system/restic-backup.service:\n[Unit] Description=Backup diario con Restic After=network.target [Service] Type=oneshot User=root EnvironmentFile=/etc/restic/env.sh ExecStart=/usr/local/bin/restic backup \\ /opt/app/ \\ /etc/nginx/ \\ /home/usuario/ \\ --exclude=\u0026#39;/opt/app/logs\u0026#39; \\ --tag servidor-web ExecStartPost=/usr/local/bin/restic forget \\ --keep-daily 7 \\ --keep-weekly 4 \\ --keep-monthly 6 \\ --tag servidor-web \\ --prune StandardOutput=journal StandardError=journal /etc/systemd/system/restic-backup.timer:\n[Unit] Description=Ejecuta backup Restic diariamente [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true RandomizedDelaySec=10min [Install] WantedBy=timers.target Activate:\nsudo systemctl daemon-reload sudo systemctl enable --now restic-backup.timer # Verificar que está activo sudo systemctl list-timers restic-backup.timer RandomizedDelaySec=10min distributes backups in random windows to prevent all servers from hitting the SFTP or S3 destination at the same time.\nEmail notifications on completion # By combining with the email notification system with msmtp, we can receive a backup summary. Modify ExecStart with a wrapper script:\n/usr/local/bin/restic-backup.sh:\n#!/bin/bash set -euo pipefail source /etc/restic/env.sh LOG=$(mktemp) STATUS=0 restic backup \\ /opt/app/ \\ /etc/nginx/ \\ /home/usuario/ \\ --exclude=\u0026#39;/opt/app/logs\u0026#39; \\ --tag servidor-web \u0026gt; \u0026#34;$LOG\u0026#34; 2\u0026gt;\u0026amp;1 || STATUS=$? restic forget \\ --keep-daily 7 \\ --keep-weekly 4 \\ --keep-monthly 6 \\ --tag servidor-web \\ --prune \u0026gt;\u0026gt; \u0026#34;$LOG\u0026#34; 2\u0026gt;\u0026amp;1 || STATUS=$? SUBJECT=\u0026#34;[Backup] $(hostname) - $(date \u0026#39;+%Y-%m-%d\u0026#39;)\u0026#34; [ $STATUS -ne 0 ] \u0026amp;\u0026amp; SUBJECT=\u0026#34;[ERROR Backup] $(hostname) - $(date \u0026#39;+%Y-%m-%d\u0026#39;)\u0026#34; cat \u0026#34;$LOG\u0026#34; | mail -s \u0026#34;$SUBJECT\u0026#34; usuario@ejemplo.com rm -f \u0026#34;$LOG\u0026#34; exit $STATUS chmod +x /usr/local/bin/restic-backup.sh And in the .service, change ExecStart and ExecStartPost with a single line:\nExecStart=/usr/local/bin/restic-backup.sh Weekly integrity check # Add a second timer for the weekly verification, which is more expensive:\n/etc/systemd/system/restic-check.service:\n[Unit] Description=Verificación semanal de integridad Restic After=network.target [Service] Type=oneshot User=root EnvironmentFile=/etc/restic/env.sh ExecStart=/usr/local/bin/restic check StandardOutput=journal StandardError=journal /etc/systemd/system/restic-check.timer:\n[Unit] Description=Verifica integridad del repositorio Restic cada semana [Timer] OnCalendar=Sun *-*-* 04:00:00 Persistent=true [Install] WantedBy=timers.target sudo systemctl enable --now restic-check.timer View the status in the system log # # Último backup journalctl -u restic-backup.service -n 50 # Seguir en tiempo real durante la ejecución manual journalctl -u restic-backup.service -f # Historial de ejecuciones del timer systemctl status restic-backup.timer Conclusion # Restic doesn\u0026rsquo;t completely replace rsync for live directory synchronization cases, but for backups with history, it\u0026rsquo;s clearly superior: built-in encryption, transparent deduplication, and browsable snapshots without additional scripts. Integration with systemd timers and email notifications closes the loop: you know the backup ran, when it failed, and you can restore any point in time in minutes.\nThe real key to any backup strategy is not the software you choose, but verifying that you can restore. Test restic restore in a temporary directory before you really need it.\n","date":"11 May 2026","externalUrl":null,"permalink":"/en/posts/restic-backups-cifrados-deduplicacion/","section":"Posts","summary":"I already had backups with rsync and cron, but rsync copies files, not snapshots. If you accidentally delete a file and the backup syncs before you notice, you lose it. Restic solves that and adds something rsync will never provide: AES-256 encryption, deduplication, and snapshots with navigable history.\nWhat makes Restic different # Feature rsync Restic AES-256 encryption No Yes Deduplication No Yes (block-level) Navigable snapshots No Yes Multiple backends No SFTP, S3, Backblaze, rclone… Integrity checking No restic check Retention policy Manual restic forget --prune Deduplication is especially useful for database backups and configuration directories that change little: a Restic repository with 6 months of daily backups usually takes up much less space than 180 full copies.\n","title":"Restic: encrypted backups with deduplication for your Linux server","type":"posts"},{"content":" El problema de la IA de usar y tirar # La mayoría de los flujos con asistentes de IA tienen el mismo patrón: abres el chat, explicas el contexto desde cero, obtienes la respuesta y cierras. La próxima vez, repites.\nEso funciona para tareas puntuales, pero no escala si quieres integrar la IA en tu flujo de trabajo diario. Cada sesión empieza en cero: sin memoria de tu infraestructura, sin conocimiento de tus convenciones, sin contexto de lo que estabas haciendo.\nLa solución que monté combina dos piezas: los skills de Claude Code y un bot de Telegram como canal de comunicación.\nQué son los skills de Claude Code # Claude Code es la CLI oficial de Anthropic para interactuar con Claude desde el terminal. Una de sus funcionalidades más potentes es el sistema de skills: scripts personalizados que se invocan como comandos dentro de la sesión.\nUn skill es básicamente un fichero Markdown con instrucciones que el asistente ejecuta al llamar a /nombre-skill. Lo importante es que esas instrucciones incluyen todo el contexto necesario: dónde están los archivos, qué convenciones seguir, qué evitar, qué herramientas usar.\n~/.claude/ └── skills/ ├── revisar-infra.md # /revisar-infra → analiza logs y estado Docker ├── publicar.md # /publicar → pipeline LinkedIn + blog └── deploy.md # /deploy → git push + CI/CD En lugar de explicar cada vez \u0026ldquo;mira en /home/tellme/CLAUDE/agente/, el .env tiene las credenciales, no uses rutas absolutas\u0026hellip;\u0026rdquo;, defines el skill una vez y lo invocas con un comando.\nEjemplo real # El skill /publicar de mi setup hace esto al invocarse:\nComprueba la cola de LinkedIn (social/linkedin-queue.json) Verifica que el token no esté próximo a caducar Genera el texto del post según el último artículo del blog Aplica las reglas de seguridad (sin IPs reales, sin rutas absolutas) Lo añade a la cola para publicación Sin el skill, eso requeriría explicar el flujo completo en cada sesión.\nTelegram como canal de acceso # El segundo ingrediente es conectar Claude Code a un bot de Telegram. Esto te permite interactuar con el asistente desde el móvil sin necesidad de estar en el terminal.\nLa integración funciona así:\nMóvil → Telegram Bot → Claude Code session → skill/tool → respuesta El control de acceso es por ID de usuario de Telegram. Solo los IDs en la lista blanca pueden activar el bot. El fichero access.json define la política:\n{ \u0026#34;dmPolicy\u0026#34;: \u0026#34;allowlist\u0026#34;, \u0026#34;allowFrom\u0026#34;: [\u0026#34;TU_ID_TELEGRAM\u0026#34;] } Con dmPolicy: \u0026quot;allowlist\u0026quot; nadie puede hacer pairing sin que tú lo apruebes explícitamente, y los IDs no autorizados simplemente no reciben respuesta.\nFlujo práctico # Desde el móvil puedo enviarle al bot:\n\u0026quot;¿Hay posts pendientes en LinkedIn?\u0026quot; → comprueba la cola y responde \u0026ldquo;Lanza el publisher\u0026rdquo; → ejecuta la rutina programada \u0026ldquo;Estado de los contenedores Docker\u0026rdquo; → revisa y resume El asistente tiene acceso al contexto del proyecto porque la sesión de Claude Code está corriendo en el servidor con acceso a todos los ficheros.\nRutinas programadas: la capa de autonomía # La tercera pieza son las rutinas remotas: agentes que se ejecutan en la nube de Anthropic según un cron, sin que tengas que estar delante.\n0 8 * * * → LinkedIn publisher 0 6 * * * → revisión de seguridad 0 8 * * 1 → borrador de post semanal Cada rutina clona el repositorio, ejecuta su script y hace push del resultado. El agente no tiene acceso a tu máquina local — trabaja contra el repo en GitLab.\nLa combinación de los tres elementos crea un sistema coherente:\nComponente Qué hace Skills Define qué puede hacer el asistente y cómo Telegram Acceso desde el móvil con control de acceso Rutinas Autonomía programada sin intervención El hardware # Todo esto corre en un servidor doméstico 24/7. Un Mini PC con procesador N100 es suficiente para Docker, el agente y el bot de Telegram con un consumo de unos 8-10W.\n→ Mini PC Intel N100 en Amazon\nNo necesitas nada en la nube para la parte local del agente. Las rutinas remotas sí corren en la infraestructura de Anthropic, pero el acceso al repo y los scripts es tuyo.\nConclusión # El valor no está en cada pieza por separado sino en la composabilidad:\nSkills → el asistente conoce tu contexto sin que lo repitas Telegram → acceso desde cualquier sitio con política de acceso clara Rutinas → autonomía programada para las tareas repetitivas El resultado es un asistente que funciona como un colaborador técnico: conoce tu infraestructura, puede actuar sobre ella y está disponible desde el móvil.\nToda la configuración está documentada en el repo de infraestructura.\n","date":"May 10, 2026","externalUrl":null,"permalink":"/posts/skills-y-agentes-claude-code-asistente-tecnico-desde-movil/","section":"Posts","summary":"El problema de la IA de usar y tirar # La mayoría de los flujos con asistentes de IA tienen el mismo patrón: abres el chat, explicas el contexto desde cero, obtienes la respuesta y cierras. La próxima vez, repites.\nEso funciona para tareas puntuales, pero no escala si quieres integrar la IA en tu flujo de trabajo diario. Cada sesión empieza en cero: sin memoria de tu infraestructura, sin conocimiento de tus convenciones, sin contexto de lo que estabas haciendo.\n","title":"Skills y agentes en Claude Code: tu asistente técnico accesible desde el móvil","type":"posts"},{"content":"","date":"6 May 2026","externalUrl":null,"permalink":"/en/tags/servidor-dom%C3%A9stico/","section":"Tags","summary":"","title":"Servidor-Doméstico","type":"tags"},{"content":" Why you need this # A few months ago I faced a common problem: I wanted to access my internal services (Jellyfin, Home Assistant, etc.) from outside my home, but I didn\u0026rsquo;t want to expose them directly on the internet. Opening ports is an unnecessary risk. The solution was to set up a VPN with Wireguard in Docker. It was the best decision I made for my home infrastructure.\nAdvantages of Wireguard # Lightweight: consumes fewer resources than OpenVPN Fast: modern and efficient protocol Easy to configure: compared to other alternatives Secure: state-of-the-art cryptography Docker-friendly: there are excellent official images Preparation # You need:\nA server with Docker installed The docker-compose.yml file A domain or public IP (to connect from outside) Wireguard clients on your devices Step-by-step installation # 1. Create the configuration directory # mkdir -p ~/wireguard/config cd ~/wireguard 2. Docker Compose # Create the docker-compose.yml file:\nversion: \u0026#39;3.8\u0026#39; services: wireguard: image: linuxserver/wireguard:latest container_name: wireguard cap_add: - NET_ADMIN - SYS_MODULE environment: - PUID=1000 - PGID=1000 - TZ=Europe/Madrid - SERVERURL=tu-dominio-o-ip-publica.com - SERVERPORT=51820 - PEERS=telefono,laptop,tablet - PEERDNS=auto ports: - \u0026#34;51820:51820/udp\u0026#34; volumes: - ./config:/config - /lib/modules:/lib/modules:ro networks: - mi-red restart: unless-stopped networks: mi-red: driver: bridge ipam: config: - subnet: 10.0.0.0/24 Replace:\ntu-dominio-o-ip-publica.com with your actual address The PEERS with the names of your devices The timezone according to your location 3. Start the container # docker-compose up -d Configuration files will be automatically generated in ./config. Wait a few seconds and verify:\nls -la config/peer_*/ 4. Get the QR codes # To connect your devices:\ndocker exec wireguard cat /config/peer_telefono/peer_telefono.conf Or directly the QR codes:\ndocker exec wireguard qrencode -t ansiutf8 \u0026lt; /config/peer_telefono/peer_telefono.conf Scan with your Wireguard client on each device.\nConnecting internal services # This is the important part. I want to access services on my internal network. To do this, I modify the docker-compose.yml and add routes:\nenvironment: - ALLOWEDIPS=10.0.0.0/24,192.168.1.0/24 This allows you to access the 192.168.1.0/24 network (your local network) from the VPN.\nOn the server, enable forwarding:\necho \u0026#34;net.ipv4.ip_forward=1\u0026#34; | sudo tee -a /etc/sysctl.conf sudo sysctl -p Access from clients # Once connected to the VPN, you access your services using their internal IPs:\nhttp://192.168.1.100:8096 for Jellyfin http://192.168.1.50:8123 for Home Assistant Whatever you need on your network Maintenance # Renew certificates (approximately every 6 months):\ndocker exec wireguard /app/wireguard-tools/show-peer peer_nombre Add a new device:\ndocker-compose down # Edita PEERS en docker-compose.yml docker-compose up -d Final notes # Open only port 51820/UDP on your router Use firewall on the server to block unnecessary access Verify that IP forwarding is active Monitor VPN traffic regularly I\u0026rsquo;ve been running this setup for several months and it\u0026rsquo;s completely stable. I access my services from anywhere without security concerns. I definitely recommend this setup to anyone who wants to keep their home infrastructure private but accessible.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring TECLAST T65 Tablet 13.4\u0026quot; Android 16 with keyboard and stylus — Portable WireGuard client: manage your services from anywhere Affiliate links. No extra cost for you.\n","date":"6 May 2026","externalUrl":null,"permalink":"/en/posts/vpn-con-wireguard-en-docker-acceso-seguro-a-servicios-internos-sin-exponer-puertos/","section":"Posts","summary":"Why you need this # A few months ago I faced a common problem: I wanted to access my internal services (Jellyfin, Home Assistant, etc.) from outside my home, but I didn’t want to expose them directly on the internet. Opening ports is an unnecessary risk. The solution was to set up a VPN with Wireguard in Docker. It was the best decision I made for my home infrastructure.\n","title":"VPN with Wireguard in Docker: Secure access to internal services without exposing ports","type":"posts"},{"content":"","date":"5 May 2026","externalUrl":null,"permalink":"/en/tags/alertas/","section":"Tags","summary":"","title":"Alertas","type":"tags"},{"content":"","date":"5 May 2026","externalUrl":null,"permalink":"/en/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":" The Problem # After spending months running containers on my home server, I got tired of discovering issues when things were already broken. A container consuming all the memory. A volume full with no warning. I needed real visibility into what was happening in my infrastructure.\nI decided to implement a monitoring stack with Prometheus and Grafana. Here I document exactly how I did it.\nArchitecture Chosen # Prometheus: collects metrics from Docker cAdvisor: exposes container metrics Grafana: visualizes everything in dashboards Alertmanager: notifies when something fails Step 1: Docker Compose with the complete stack # I created a docker-compose.yml file that brings everything up together:\nversion: \u0026#39;3.8\u0026#39; services: prometheus: image: prom/prometheus:latest container_name: prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - ./alertas.yml:/etc/prometheus/alertas.yml - prometheus_data:/prometheus ports: - \u0026#34;9090:9090\u0026#34; command: - \u0026#39;--config.file=/etc/prometheus/prometheus.yml\u0026#39; - \u0026#39;--storage.tsdb.path=/prometheus\u0026#39; networks: - monitoring cadvisor: image: gcr.io/cadvisor/cadvisor:latest container_name: cadvisor volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro ports: - \u0026#34;8080:8080\u0026#34; networks: - monitoring grafana: image: grafana/grafana:latest container_name: grafana ports: - \u0026#34;3000:3000\u0026#34; volumes: - grafana_data:/var/lib/grafana environment: - GF_SECURITY_ADMIN_PASSWORD=admin networks: - monitoring alertmanager: image: prom/alertmanager:latest container_name: alertmanager volumes: - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml - alertmanager_data:/alertmanager ports: - \u0026#34;9093:9093\u0026#34; networks: - monitoring volumes: prometheus_data: grafana_data: alertmanager_data: networks: monitoring: driver: bridge Step 2: Configure Prometheus # File prometheus.yml:\nglobal: scrape_interval: 15s evaluation_interval: 15s alerting: alertmanagers: - static_configs: - targets: - alertmanager:9093 rule_files: - \u0026#39;/etc/prometheus/alertas.yml\u0026#39; scrape_configs: - job_name: \u0026#39;cadvisor\u0026#39; static_configs: - targets: [\u0026#39;cadvisor:8080\u0026#39;] - job_name: \u0026#39;prometheus\u0026#39; static_configs: - targets: [\u0026#39;localhost:9090\u0026#39;] Step 3: Define the alerts # File alertas.yml:\ngroups: - name: docker_alerts interval: 10s rules: - alert: HighCPUUsage expr: \u0026#39;rate(container_cpu_usage_seconds_total[5m]) \u0026gt; 0.8\u0026#39; for: 2m annotations: summary: \u0026#34;CPU alta en contenedor {{ $labels.name }}\u0026#34; description: \u0026#34;{{ $labels.name }} está usando {{ $value | humanizePercentage }} de CPU\u0026#34; - alert: HighMemoryUsage expr: \u0026#39;container_memory_usage_bytes / container_spec_memory_limit_bytes \u0026gt; 0.85\u0026#39; for: 2m annotations: summary: \u0026#34;Memoria alta en {{ $labels.name }}\u0026#34; description: \u0026#34;Uso de memoria: {{ $value | humanizePercentage }}\u0026#34; - alert: DiskSpaceRunningOut expr: \u0026#39;node_filesystem_avail_bytes{mountpoint=\u0026#34;/\u0026#34;} / node_filesystem_size_bytes{mountpoint=\u0026#34;/\u0026#34;} \u0026lt; 0.1\u0026#39; for: 5m annotations: summary: \u0026#34;Espacio en disco por debajo del 10%\u0026#34; Step 4: Configure Alertmanager # File alertmanager.yml:\nglobal: resolve_timeout: 5m route: receiver: \u0026#39;console\u0026#39; group_by: [\u0026#39;alertname\u0026#39;] group_wait: 10s group_interval: 10s repeat_interval: 1h receivers: - name: \u0026#39;console\u0026#39; webhook_configs: - url: \u0026#39;http://localhost:5001/\u0026#39; Step 5: Start and verify # docker-compose up -d Access:\nPrometheus: http://localhost:9090 Grafana: http://localhost:3000 cAdvisor: http://localhost:8080 Step 6: Create dashboards in Grafana # In Grafana I imported the public dashboard 893 (Docker and Host Monitoring) which works directly with cAdvisor.\nResult # Now I have complete visibility. I receive alerts when:\nA container consumes more than 80% CPU for 2 minutes Memory exceeds 85% of the limit Disk drops below 10% The complete setup takes up less than 500MB of RAM at rest and has already saved me several scares. It\u0026rsquo;s worth it.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"5 May 2026","externalUrl":null,"permalink":"/en/posts/monitorizacion-de-contenedores-docker-con-prometheus-y-grafana-alertas-automaticas-en-casa/","section":"Posts","summary":"The Problem # After spending months running containers on my home server, I got tired of discovering issues when things were already broken. A container consuming all the memory. A volume full with no warning. I needed real visibility into what was happening in my infrastructure.\nI decided to implement a monitoring stack with Prometheus and Grafana. Here I document exactly how I did it.\n","title":"Monitoring Docker containers with Prometheus and Grafana: automatic alerts at home","type":"posts"},{"content":"","date":"5 May 2026","externalUrl":null,"permalink":"/en/tags/prometheus/","section":"Tags","summary":"","title":"Prometheus","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/auth/","section":"Tags","summary":"","title":"Auth","type":"tags"},{"content":" The Problem # A few days ago I tried to automate sending emails from my home server. Nothing complicated: a Python script with smtplib to send notifications. The problem came when I configured a password with special characters: MiPasw0rd$Ñ.\nimport smtplib server = smtplib.SMTP(\u0026#39;mail.example.com\u0026#39;, 587) server.starttls() server.login(\u0026#39;usuario@example.com\u0026#39;, \u0026#39;MiPasw0rd$Ñ\u0026#39;) # Error 535 Result: SMTPAuthenticationError: (535, b'5.7.8 Authentication credentials invalid')\nThe strange thing is that the password was correct. I could access it manually without problems. Error 535 suggested authentication failures, but the real problem was in the encoding.\nWhy It Fails # The fault lies in smtplib\u0026rsquo;s encoding handling. The login() method uses UTF-8 by default, but then applies transformations that don\u0026rsquo;t always respect special characters correctly, especially when the server or library have legacy configurations.\nIn my case, the SMTP server expected consistent UTF-8 encoding. When smtplib processed the password with $ and Ñ, something along the way got corrupted.\nThe Solution: Manual AUTH LOGIN # The AUTH LOGIN protocol is simple: username and password are encoded in base64 separately and sent in manual steps. This gives you complete control over the encoding.\nimport smtplib import base64 def auth_login_manual(server, usuario, contraseña): \u0026#34;\u0026#34;\u0026#34;Implementa AUTH LOGIN manualmente con UTF-8\u0026#34;\u0026#34;\u0026#34; # Respuesta inicial del servidor code, response = server.docmd(\u0026#34;AUTH LOGIN\u0026#34;) # Codificar usuario en base64 UTF-8 usuario_b64 = base64.b64encode(usuario.encode(\u0026#39;utf-8\u0026#39;)).decode(\u0026#39;ascii\u0026#39;) code, response = server.docmd(usuario_b64) # Codificar contraseña en base64 UTF-8 contraseña_b64 = base64.b64encode(contraseña.encode(\u0026#39;utf-8\u0026#39;)).decode(\u0026#39;ascii\u0026#39;) code, response = server.docmd(contraseña_b64) # Verificar éxito (código 235 es autenticación exitosa) if code != 235: raise Exception(f\u0026#34;Autenticación fallida: {code} {response}\u0026#34;) return True # Uso server = smtplib.SMTP(\u0026#39;mail.example.com\u0026#39;, 587) server.starttls() auth_login_manual(server, \u0026#39;usuario@example.com\u0026#39;, \u0026#39;MiPasw0rd$Ñ\u0026#39;) print(\u0026#34;Autenticado exitosamente\u0026#34;) Protocol Breakdown # AUTH LOGIN: The client requests to use the AUTH LOGIN mechanism Username in base64: The server responds with 334, expects the encoded username Password in base64: The server responds with 334, expects the encoded password Response 235: Indicates successful authentication The docmd() method sends raw SMTP commands and returns the response code and message.\nTesting on My Server # I implemented this in my home infrastructure with Postfix. The difference is noticeable:\n# Antes: falla con caracteres especiales server.login(\u0026#39;admin@local.home\u0026#39;, \u0026#39;P@ssw0rdÑ\u0026#39;) # Error 535 # Después: funciona perfectamente auth_login_manual(server, \u0026#39;admin@local.home\u0026#39;, \u0026#39;P@ssw0rdÑ\u0026#39;) # OK Security Considerations # Base64 is not encryption: always use STARTTLS or direct connection to port 465 with SSL UTF-8 encoding is safe for special characters This method is compatible with any SMTP server that supports AUTH LOGIN Complete Script # import smtplib import base64 class SMTPConnection: def __init__(self, host, port=587): self.server = smtplib.SMTP(host, port) self.server.starttls() def login(self, usuario, contraseña): self.server.docmd(\u0026#34;AUTH LOGIN\u0026#34;) self.server.docmd(base64.b64encode(usuario.encode(\u0026#39;utf-8\u0026#39;)).decode(\u0026#39;ascii\u0026#39;)) code, resp = self.server.docmd(base64.b64encode(contraseña.encode(\u0026#39;utf-8\u0026#39;)).decode(\u0026#39;ascii\u0026#39;)) if code != 235: raise Exception(f\u0026#34;Auth failed: {code}\u0026#34;) def send(self, de, para, asunto, cuerpo): msg = f\u0026#34;From: {de}\\nTo: {para}\\nSubject: {asunto}\\n\\n{cuerpo}\u0026#34; self.server.sendmail(de, para, msg) def close(self): self.server.quit() # Uso smtp = SMTPConnection(\u0026#39;mail.example.com\u0026#39;) smtp.login(\u0026#39;usuario@example.com\u0026#39;, \u0026#39;MiPasw0rd$Ñ\u0026#39;) smtp.send(\u0026#39;usuario@example.com\u0026#39;, \u0026#39;destino@example.com\u0026#39;, \u0026#39;Test\u0026#39;, \u0026#39;Funciona!\u0026#39;) smtp.close() Conclusion # If your SMTP server rejects passwords with special characters, it\u0026rsquo;s not a mystery. It\u0026rsquo;s an encoding problem. Implementing AUTH LOGIN manually gives you complete control and works with any password.\nI deployed it in production on my home server and haven\u0026rsquo;t had any problems since.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-consumption server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/auth-login-manual-en-python-con-smtplib-caracteres-especiales-y-error-535/","section":"Posts","summary":"The Problem # A few days ago I tried to automate sending emails from my home server. Nothing complicated: a Python script with smtplib to send notifications. The problem came when I configured a password with special characters: MiPasw0rd$Ñ.\nimport smtplib server = smtplib.SMTP('mail.example.com', 587) server.starttls() server.login('usuario@example.com', 'MiPasw0rd$Ñ') # Error 535 Result: SMTPAuthenticationError: (535, b'5.7.8 Authentication credentials invalid')\nThe strange thing is that the password was correct. I could access it manually without problems. Error 535 suggested authentication failures, but the real problem was in the encoding.\n","title":"AUTH LOGIN manual in Python with smtplib: special characters and error 535","type":"posts"},{"content":" The Problem # Recently I lost a hard drive without warning. It wasn\u0026rsquo;t catastrophic because I had backups, but it made me aware that many hobbyists with home servers have no data protection strategy at all. If your Docker server crashes tomorrow, how long would it take you to recover it?\nIn this article I share how I automated backups of my Docker infrastructure using rsync and cron. It\u0026rsquo;s simple, efficient, and it works.\nThe Strategy # My approach is straightforward:\nrsync to incrementally sync only what changed cron to automate daily execution An external USB drive as the backup destination Retention of multiple snapshots for granular recovery This isn\u0026rsquo;t cloud backup. It\u0026rsquo;s local backup, fast, and under my control.\nStep-by-Step Setup # 1. Prepare the Storage # I connected a USB drive and mounted it at /mnt/backup. Verify it\u0026rsquo;s available:\nlsblk mount | grep backup 2. Backup Script # I created /usr/local/bin/docker-backup.sh:\n#!/bin/bash BACKUP_DEST=\u0026#34;/mnt/backup\u0026#34; DOCKER_DATA=\u0026#34;/var/lib/docker\u0026#34; COMPOSE_DIR=\u0026#34;/home/user/docker-compose\u0026#34; TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_PATH=\u0026#34;$BACKUP_DEST/backups/$TIMESTAMP\u0026#34; # Crear directorio de backup mkdir -p \u0026#34;$BACKUP_PATH\u0026#34; # Backup de volúmenes Docker echo \u0026#34;[$(date)] Iniciando backup de Docker volumes...\u0026#34; rsync -av --delete \u0026#34;$DOCKER_DATA/volumes/\u0026#34; \u0026#34;$BACKUP_PATH/volumes/\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 # Backup de configuraciones docker-compose echo \u0026#34;[$(date)] Iniciando backup de docker-compose...\u0026#34; rsync -av \u0026#34;$COMPOSE_DIR/\u0026#34; \u0026#34;$BACKUP_PATH/compose/\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 # Backup incremental de datos de aplicaciones echo \u0026#34;[$(date)] Iniciando backup de datos...\u0026#34; rsync -av --delete \u0026#34;/home/user/app-data/\u0026#34; \u0026#34;$BACKUP_PATH/app-data/\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log 2\u0026gt;\u0026amp;1 # Limpiar backups más antiguos (mantener 7 últimos) echo \u0026#34;[$(date)] Limpiando backups antiguos...\u0026#34; ls -t \u0026#34;$BACKUP_DEST/backups\u0026#34; | tail -n +8 | xargs -I {} rm -rf \u0026#34;$BACKUP_DEST/backups/{}\u0026#34; echo \u0026#34;[$(date)] Backup completado\u0026#34; \u0026gt;\u0026gt; /var/log/docker-backup.log Make it executable:\nchmod +x /usr/local/bin/docker-backup.sh 3. Configure cron # I edited the root user\u0026rsquo;s cron table:\nsudo crontab -e I added this line to run at 2 AM every day:\n0 2 * * * /usr/local/bin/docker-backup.sh To verify it\u0026rsquo;s registered:\nsudo crontab -l 4. Monitoring # I created a second script to alert me if something fails. In /usr/local/bin/check-backup.sh:\n#!/bin/bash LAST_BACKUP=$(ls -t /mnt/backup/backups | head -1) BACKUP_TIME=$(date -d \u0026#34;$(stat -c %y /mnt/backup/backups/$LAST_BACKUP | cut -d\u0026#39; \u0026#39; -f1)\u0026#34; +%s) CURRENT_TIME=$(date +%s) DIFF=$((($CURRENT_TIME - $BACKUP_TIME) / 3600)) if [ $DIFF -gt 25 ]; then echo \u0026#34;ALERTA: No hay backup desde hace $DIFF horas\u0026#34; else echo \u0026#34;Último backup: $DIFF horas atrás - OK\u0026#34; fi I run it manually each week or via cron if I want:\nchmod +x /usr/local/bin/check-backup.sh /usr/local/bin/check-backup.sh Important Considerations # Disk Space: rsync with --delete syncs the source exactly. I check that the destination has at least 1.5x the size of the Docker data.\nPermissions: The script runs as root, so it can access /var/lib/docker. If you use a regular user, you\u0026rsquo;ll need special permissions.\nTesting: Once a month I simulate a recovery by restoring a random file to a test machine. A backup that was never tested doesn\u0026rsquo;t exist.\nEncryption: My USB drive is at home with me, so I don\u0026rsquo;t encrypt it. If you kept it elsewhere, consider --backup-dir with synchronization to an encrypted destination.\nResult # Now I sleep better. Every night at 2 AM, Docker, configurations, and data sync automatically. If the server dies, I recover everything in 30 minutes.\nThe key is: simple automation, manual verification. Don\u0026rsquo;t wait for something to fail to test your backup.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/backups-automaticos-con-rsync-y-cron-para-docker-domestico/","section":"Posts","summary":"The Problem # Recently I lost a hard drive without warning. It wasn’t catastrophic because I had backups, but it made me aware that many hobbyists with home servers have no data protection strategy at all. If your Docker server crashes tomorrow, how long would it take you to recover it?\nIn this article I share how I automated backups of my Docker infrastructure using rsync and cron. It’s simple, efficient, and it works.\n","title":"Automatic backups with rsync and cron for home Docker","type":"posts"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/cron/","section":"Tags","summary":"","title":"Cron","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/disaster-recovery/","section":"Tags","summary":"","title":"Disaster-Recovery","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/docker-compose/","section":"Tags","summary":"","title":"Docker-Compose","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/encoding/","section":"Tags","summary":"","title":"Encoding","type":"tags"},{"content":" The Real Problem # I\u0026rsquo;ve been running services on my home server with Docker Compose for months. Recently I tried setting a password with special characters in my .env file. The password was something like Pass$word123!@. When starting the containers, the variable arrived empty or malformed. After investigating, I discovered that Docker Compose was interpreting the $ as a reference to another variable.\nWhy It Happens: Variable Interpolation # Docker Compose interprets the .env file in a special way. When it encounters a $ followed by a valid variable name, it tries to substitute it with its value. If that variable doesn\u0026rsquo;t exist, it leaves it empty or generates a silent error.\nExample of the problem:\n# .env DB_PASSWORD=Pass$word123 API_KEY=sk_test_$random_key SECRET=$UNDEFINED_VAR In these cases, Docker Compose will look for variables called word123, random_key, and UNDEFINED_VAR. Obviously it won\u0026rsquo;t find them, and your values will become corrupted.\nThe Solution: Single Quotes # The most reliable solution is to wrap values in single quotes. Single quotes prevent variable interpolation in Docker Compose, exactly like they work in bash.\n# .env - FORMA CORRECTA DB_PASSWORD=\u0026#39;Pass$word123\u0026#39; API_KEY=\u0026#39;sk_test_$random_key\u0026#39; SECRET=\u0026#39;$UNDEFINED_VAR\u0026#39; COMPLEX=\u0026#39;!@#$%^\u0026amp;*()\u0026#39; With single quotes, Docker Compose treats the entire content as literal text. It doesn\u0026rsquo;t try to resolve variable references.\nUsing Variables in docker-compose.yml # Once correctly defined in .env, using them in your docker-compose.yml is straightforward:\nversion: \u0026#39;3.8\u0026#39; services: database: image: postgres:15 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USER} api: image: mi-api:latest environment: API_KEY: ${API_KEY} DB_SECRET: ${SECRET} Docker Compose will load the values from .env and inject the variables correctly into the containers.\nThe Second Problem: Restart Doesn\u0026rsquo;t Reload Variables # Here comes the frustrating part. After modifying your .env file, you run:\ndocker-compose restart And you discover that the containers are still using the old values. This happens because restart only restarts existing containers without recreating them. It doesn\u0026rsquo;t read the .env file again.\nThe Solution: \u0026ndash;force-recreate # For Docker Compose to read the .env file again and apply the new variables, you must recreate the containers. The correct command is:\ndocker-compose up -d --force-recreate Or if you prefer a more explicit sequence:\ndocker-compose down docker-compose up -d The --force-recreate option forces recreation even if the image hasn\u0026rsquo;t changed. Without it, Docker Compose might reuse existing containers.\nMy Current Workflow # After experimenting with this, here\u0026rsquo;s how I handle variables on my server:\nDefine everything in .env with single quotes:\nMYSQL_ROOT_PASSWORD=\u0026#39;R00t!$pecial#Pass\u0026#39; MYSQL_USER=\u0026#39;admin\u0026#39; MYSQL_PASSWORD=\u0026#39;Pass$word@123\u0026#39; Reference in docker-compose.yml:\nenvironment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD} After modifying .env, always use:\ndocker-compose up -d --force-recreate Verify that the changes were applied:\ndocker-compose exec servicio env | grep MI_VAR Lessons Learned # Special characters $, !, @, # in values require single quotes restart only restarts, it doesn\u0026rsquo;t reload configuration --force-recreate is essential after modifying .env Always verify that variables have been loaded correctly inside the container These details saved many hours of debugging in my home setup. I hope they save you some too.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/variables-de-entorno-con-caracteres-especiales-en-docker-compose-el-problema-del-dollar-y-como-recrear-contenedores/","section":"Posts","summary":"The Real Problem # I’ve been running services on my home server with Docker Compose for months. Recently I tried setting a password with special characters in my .env file. The password was something like Pass$word123!@. When starting the containers, the variable arrived empty or malformed. After investigating, I discovered that Docker Compose was interpreting the $ as a reference to another variable.\n","title":"Environment variables with special characters in Docker Compose: the dollar sign problem and how to recreate containers","type":"posts"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/excel/","section":"Tags","summary":"","title":"Excel","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/fastapi/","section":"Tags","summary":"","title":"Fastapi","type":"tags"},{"content":" The Problem # Recently I needed to sync an SMB share from a NAS with my Linux server. The obvious solution would be smbclient or mount -t cifs, but I wanted:\nIncremental synchronization (only new or modified files) Detect files deleted from the share Control NTLM authentication directly from code Silence the obscene amount of logs that smbprotocol spits out The Python smbprotocol library solved all of this, but there\u0026rsquo;s no documentation on how to do it properly. Here\u0026rsquo;s my solution.\nInitial Setup # Install the dependencies:\npip install smbprotocol sqlalchemy pydantic python-dotenv The basic idea: maintain a SQLite database with a record of all synchronized files (name, MD5 hash, timestamp). Each execution compares the current share with the database and processes only changes.\nSilencing smbprotocol logs # This is critical. Without controlling it, the library fills your console with debug messages:\nimport logging # Silenciar smbprotocol logging.getLogger(\u0026#39;smbprotocol\u0026#39;).setLevel(logging.WARNING) logging.getLogger(\u0026#39;smbprotocol.connection\u0026#39;).setLevel(logging.WARNING) logging.getLogger(\u0026#39;smbprotocol.session\u0026#39;).setLevel(logging.WARNING) # Tu logger logger = logging.getLogger(\u0026#39;sync_smb\u0026#39;) logger.setLevel(logging.INFO) handler = logging.StreamHandler() handler.setFormatter(logging.Formatter(\u0026#39;%(asctime)s - %(levelname)s - %(message)s\u0026#39;)) logger.addHandler(handler) This reduces logs to something reasonable. Without this, each operation generates 50 lines of garbage.\nSQLite Database Structure # from datetime import datetime from sqlalchemy import create_engine, Column, String, DateTime, Integer from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker Base = declarative_base() class SyncedFile(Base): __tablename__ = \u0026#39;synced_files\u0026#39; filename = Column(String, primary_key=True) md5_hash = Column(String) file_size = Column(Integer) last_modified = Column(DateTime) sync_timestamp = Column(DateTime, default=datetime.utcnow) engine = create_engine(\u0026#39;sqlite:///smb_sync.db\u0026#39;) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) NTLM Connection # from smbprotocol.session import Session from smbprotocol.tree import TreeConnect import socket username = \u0026#34;DOMINIO\\\\usuario\u0026#34; # Formato NETBIOS\\usuario password = \u0026#34;contraseña\u0026#34; host = \u0026#34;192.168.x.x\u0026#34; share = \u0026#34;compartido\u0026#34; # Conexión básica connection = smbprotocol.connection.Connection( uuid.uuid4(), host, 445, ) connection.connect() session = Session(connection, username, password) session.connect() tree = TreeConnect(session, f\u0026#34;\\\\\\\\{host}\\\\{share}\u0026#34;) tree.connect() NTLM is negotiated automatically. You don\u0026rsquo;t need to do anything special, but make sure you use the correct DOMINIO\\usuario format.\nIncremental Synchronization # import hashlib from pathlib import Path def get_file_hash(file_data): \u0026#34;\u0026#34;\u0026#34;Calcula MD5 de contenido en bytes\u0026#34;\u0026#34;\u0026#34; return hashlib.md5(file_data).hexdigest() def sync_smb_share(local_path: Path): session = Session() remote_files = {} # Listar archivos del share directory = tree.open_file(share, FileAttributes.DIRECTORY, CreateOptions.FILE_DIRECTORY_FILE) for file_info in directory.query_directory(): if file_info.file_attributes \u0026amp; FileAttributes.DIRECTORY: continue # Ignorar carpetas por ahora filename = file_info.file_name remote_files[filename] = { \u0026#39;size\u0026#39;: file_info.end_of_file, \u0026#39;modified\u0026#39;: file_info.change_time.timestamp() } # Leer archivos nuevos o modificados local_db = session.query(SyncedFile).all() local_files = {f.filename: f for f in local_db} for filename, info in remote_files.items(): # Nuevo o modificado if filename not in local_files or local_files[filename].file_size != info[\u0026#39;size\u0026#39;]: logger.info(f\u0026#34;Descargando: {filename}\u0026#34;) file_obj = tree.open_file(filename) content = b\u0026#34;\u0026#34; for chunk in file_obj: content += chunk md5 = get_file_hash(content) (local_path / filename).write_bytes(content) # Actualizar BD sync_record = local_files.get(filename) or SyncedFile() sync_record.filename = filename sync_record.md5_hash = md5 sync_record.file_size = info[\u0026#39;size\u0026#39;] sync_record.last_modified = datetime.fromtimestamp(info[\u0026#39;modified\u0026#39;]) session.merge(sync_record) session.commit() # Detectar eliminados for filename in local_files: if filename not in remote_files: logger.warning(f\u0026#34;Archivo eliminado en remoto: {filename}\u0026#34;) (local_path / filename).unlink(missing_ok=True) session.query(SyncedFile).filter_by(filename=filename).delete() session.commit() tree.close() session.close() if __name__ == \u0026#34;__main__\u0026#34;: sync_smb_share(Path(\u0026#34;/mnt/sync\u0026#34;)) Automation with cron # 0 */4 * * * /usr/bin/python3 /opt/sync_smb/sync.py \u0026gt;\u0026gt; /var/log/smb_sync.log 2\u0026gt;\u0026amp;1 This syncs every 4 hours.\nConclusion # With this setup you process only changes, control NTLM authentication without weird tricks, and have readable logs. The SQLite database is efficient even with thousands of files.\nI\u0026rsquo;ve used this in production for months without issues.\nRecommended Equipment # Intel N100 Mini PC — Silent and efficient mini PC for 24/7 home server Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Affiliate links. No extra cost to you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/sincronizacion-incremental-desde-smb-con-smbprotocol-en-linux-autenticacion-ntlm-y-control-de-logs/","section":"Posts","summary":"The Problem # Recently I needed to sync an SMB share from a NAS with my Linux server. The obvious solution would be smbclient or mount -t cifs, but I wanted:\nIncremental synchronization (only new or modified files) Detect files deleted from the share Control NTLM authentication directly from code Silence the obscene amount of logs that smbprotocol spits out The Python smbprotocol library solved all of this, but there’s no documentation on how to do it properly. Here’s my solution.\n","title":"Incremental synchronization from SMB with smbprotocol on Linux: NTLM authentication and log control","type":"posts"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/infraestructura/","section":"Tags","summary":"","title":"Infraestructura","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/ntlm/","section":"Tags","summary":"","title":"Ntlm","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/openpyxl/","section":"Tags","summary":"","title":"Openpyxl","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/performance/","section":"Tags","summary":"","title":"Performance","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/procesamiento-datos/","section":"Tags","summary":"","title":"Procesamiento-Datos","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/producci%C3%B3n/","section":"Tags","summary":"","title":"Producción","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/regex/","section":"Tags","summary":"","title":"Regex","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/rsync/","section":"Tags","summary":"","title":"Rsync","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/servidor/","section":"Tags","summary":"","title":"Servidor","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/sincronizaci%C3%B3n/","section":"Tags","summary":"","title":"Sincronización","type":"tags"},{"content":"I\u0026rsquo;ve been working with Excel spreadsheets that arrive from different departments. Each one uses different column names, the data is dirty (phone numbers with notes, tax IDs mixed with text), and codes have inconsistent formats. Here I document the solution I built.\nThe real problem # I was receiving Excel files where:\nColumns called \u0026ldquo;NIF\u0026rdquo; in one, \u0026ldquo;CIF\u0026rdquo; in another, \u0026ldquo;Identification\u0026rdquo; in the third Phone numbers like \u0026ldquo;123-456-7890 (ext 5)\u0026rdquo;, \u0026ldquo;9876543210 - unavailable\u0026rdquo; Tax IDs with dashes, spaces and varied letters Product codes with inconsistent prefixes I couldn\u0026rsquo;t expect everyone to format the same way. I needed a system that was flexible.\nSolution architecture # My approach uses three layers:\nColumn detection by regex (finds \u0026ldquo;nif\u0026rdquo;, \u0026ldquo;cif\u0026rdquo;, \u0026ldquo;identification\u0026rdquo; with fuzzy matching) Specialized cleaners for each data type Validation and logging for audit Step-by-step implementation # 1. Load the file and detect columns # from openpyxl import load_workbook import re def detectar_columnas(archivo_excel): wb = load_workbook(archivo_excel) ws = wb.active encabezados = [cell.value for cell in ws[1]] mapa_columnas = { \u0026#39;nif\u0026#39;: detectar_columna(encabezados, r\u0026#39;nif|cif|identificaci\u0026#39;), \u0026#39;telefono\u0026#39;: detectar_columna(encabezados, r\u0026#39;telef|phone|contacto\u0026#39;), \u0026#39;nombre\u0026#39;: detectar_columna(encabezados, r\u0026#39;nombre|name|razón\u0026#39;), \u0026#39;codigo\u0026#39;: detectar_columna(encabezados, r\u0026#39;código|code|articulo\u0026#39;) } return wb, ws, mapa_columnas def detectar_columna(encabezados, patron): for idx, encabezado in enumerate(encabezados): if encabezado and re.search(patron, str(encabezado).lower()): return idx return None 2. Clean and validate NIF/CIF # class LimpiadorNIF: PATRON_NIF = r\u0026#39;^([0-9]{8}[A-Z]|[XYZ][0-9]{7}[A-Z])$\u0026#39; @staticmethod def limpiar(valor): if not valor: return None # Extraer solo números y letras limpio = re.sub(r\u0026#39;[^A-Z0-9]\u0026#39;, \u0026#39;\u0026#39;, str(valor).upper()) # Si tiene más de 9 caracteres, puede ser NIF + texto match = re.search(r\u0026#39;([0-9]{8}[A-Z]|[XYZ][0-9]{7}[A-Z])\u0026#39;, limpio) if match: return match.group(1) return None if not re.match(LimpiadorNIF.PATRON_NIF, limpio) else limpio 3. Extract \u0026ldquo;clean\u0026rdquo; phone numbers # class LimpiadorTelefono: @staticmethod def limpiar(valor): if not valor: return None, None texto = str(valor) # Extraer solo números (mínimo 9 dígitos) numeros = re.sub(r\u0026#39;\\D\u0026#39;, \u0026#39;\u0026#39;, texto) telefono = numeros[-9:] if len(numeros) \u0026gt;= 9 else None # Detectar notas (texto entre paréntesis o después de guiones) notas = re.search(r\u0026#39;(\\([^)]+\\)|-.+)\u0026#39;, texto) nota = notas.group(1).strip() if notas else None return telefono, nota 4. Normalize codes with variable prefixes # class LimpiadorCodigo: @staticmethod def limpiar(valor, prefijo_esperado=\u0026#39;PRD\u0026#39;): if not valor: return None # Convertir a mayúsculas y eliminar espacios limpio = str(valor).upper().strip() # Extraer la parte numérica match = re.search(r\u0026#39;([A-Z]*)?(\\d+)\u0026#39;, limpio) if match: numero = match.group(2) # Garantizar formato consistente return f\u0026#34;{prefijo_esperado}{numero.zfill(6)}\u0026#34; return None 5. Process the complete file # def importar_excel(ruta_archivo): wb, ws, columnas = detectar_columnas(ruta_archivo) registros = [] errores = [] for fila_idx, fila in enumerate(ws.iter_rows(min_row=2, values_only=False), start=2): registro = {} try: if columnas[\u0026#39;nif\u0026#39;] is not None: nif_raw = fila[columnas[\u0026#39;nif\u0026#39;]].value registro[\u0026#39;nif\u0026#39;] = LimpiadorNIF.limpiar(nif_raw) if columnas[\u0026#39;telefono\u0026#39;] is not None: tel_raw = fila[columnas[\u0026#39;telefono\u0026#39;]].value tel, nota = LimpiadorTelefono.limpiar(tel_raw) registro[\u0026#39;telefono\u0026#39;] = tel registro[\u0026#39;nota_telefono\u0026#39;] = nota if columnas[\u0026#39;codigo\u0026#39;] is not None: cod_raw = fila[columnas[\u0026#39;codigo\u0026#39;]].value registro[\u0026#39;codigo\u0026#39;] = LimpiadorCodigo.limpiar(cod_raw) registros.append(registro) except Exception as e: errores.append(f\u0026#34;Fila {fila_idx}: {str(e)}\u0026#34;) return registros, errores Production use # registros, errores = importar_excel(\u0026#39;ventas_marzo.xlsx\u0026#39;) if errores: print(f\u0026#34;⚠️ {len(errores)} errores encontrados:\u0026#34;) for error in errores[:5]: print(f\u0026#34; - {error}\u0026#34;) print(f\u0026#34;✓ {len(registros)} registros procesados correctamente\u0026#34;) Lessons learned # Regex is the gold standard for dirty data Always return the original + cleaned data for audit Error logging by row makes debugging easier Flexible column detection saved hours of support This system has been in production for 6 months. I\u0026rsquo;ve only had to add 2 more cleaners. The key is keeping each cleaner independent.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect base for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/importacion-inteligente-de-excel-en-python-deteccion-flexible-de-columnas-y-limpieza-de-datos-heterogeneos/","section":"Posts","summary":"I’ve been working with Excel spreadsheets that arrive from different departments. Each one uses different column names, the data is dirty (phone numbers with notes, tax IDs mixed with text), and codes have inconsistent formats. Here I document the solution I built.\nThe real problem # I was receiving Excel files where:\nColumns called “NIF” in one, “CIF” in another, “Identification” in the third Phone numbers like “123-456-7890 (ext 5)”, “9876543210 - unavailable” Tax IDs with dashes, spaces and varied letters Product codes with inconsistent prefixes I couldn’t expect everyone to format the same way. I needed a system that was flexible.\n","title":"Smart Excel Import in Python: Flexible Column Detection and Heterogeneous Data Cleaning","type":"posts"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/smb/","section":"Tags","summary":"","title":"Smb","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/smbprotocol/","section":"Tags","summary":"","title":"Smbprotocol","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/smtp/","section":"Tags","summary":"","title":"Smtp","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/smtplib/","section":"Tags","summary":"","title":"Smtplib","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/sqlite/","section":"Tags","summary":"","title":"Sqlite","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/systemd/","section":"Tags","summary":"","title":"Systemd","type":"tags"},{"content":" Why I Left Cron # I\u0026rsquo;ve been using cron on my servers for years. It\u0026rsquo;s simple, reliable, and it works. But recently I discovered systemd timers and I\u0026rsquo;m not going back. The main reason: integrated logs in journald, no .log files scattered around the system, and better control over what happens when the server starts or reboots.\nIn my specific case, I had a backup that wouldn\u0026rsquo;t run if the server was off at the scheduled time. With cron, it simply got lost. With systemd timers and Persistent=true, the task runs as soon as the server boots up.\nBasic structure: .service + .timer # Systemd needs two files:\nThe service (/etc/systemd/system/mibackup.service):\n[Unit] Description=Backup diario de datos After=network.target [Service] Type=oneshot User=backup ExecStart=/usr/local/bin/backup.sh StandardOutput=journal StandardError=journal The timer (/etc/systemd/system/mibackup.timer):\n[Unit] Description=Ejecuta backup diariamente [Timer] OnCalendar=daily OnCalendar=*-*-* 03:00:00 Persistent=true Accuracy=1min [Install] WantedBy=timers.target Then:\nsudo systemctl daemon-reload sudo systemctl enable --now mibackup.timer OnCalendar: the syntax you need # OnCalendar is systemd\u0026rsquo;s cron, but more readable:\ndaily → every day at midnight weekly → every Monday at midnight hourly → every hour *-*-* 03:00:00 → every day at 3 AM Mon *-*-* 14:30:00 → every Monday at 14:30 *-01,04,07,10-01 00:00:00 → first day of every quarter You can combine multiple OnCalendar lines:\nOnCalendar=*-*-* 03:00:00 OnCalendar=*-*-* 15:00:00 This runs the task twice a day.\nPersistent=true: the change that convinced me # By default, if your server is off when a task is scheduled, systemd simply ignores it. With Persistent=true, systemd remembers and runs the task the next time it boots.\nOn my home server, this is critical. It\u0026rsquo;s not always on, and I need to guarantee that my backups run even if hours have passed.\n[Timer] OnCalendar=daily Persistent=true Type=oneshot: for tasks that finish # The Type=oneshot parameter in the .service indicates that the process will terminate. It\u0026rsquo;s normal for backup scripts, synchronization, etc.\nIf you use Type=simple (the default), systemd expects the process to keep running. That\u0026rsquo;s not what we want here.\nView logs without external files # Here\u0026rsquo;s the best part: forget about \u0026gt;\u0026gt; /var/log/mibackup.log.\nLogs go straight to journald:\n# Ver los últimos logs del timer journalctl -u mibackup.service -n 50 # Ver en tiempo real journalctl -u mibackup.service -f # Los últimos 2 horas journalctl -u mibackup.service --since \u0026#34;2 hours ago\u0026#34; # Con niveles de prioridad journalctl -u mibackup.service -p err Inside your script you can log with echo or logger:\n#!/bin/bash logger \u0026#34;Iniciando backup...\u0026#34; /backup/script.sh logger \u0026#34;Backup completado\u0026#34; Everything is captured automatically.\nChecklist of what we did # ✅ You created a .service with Type=oneshot ✅ You created a .timer with OnCalendar and Persistent=true ✅ You reloaded systemd and activated the timer ✅ You verify logs with journalctl, no extra log files Final tip # Before relying on a timer, test it manually:\n# Ejecutar el servicio ahora sudo systemctl start mibackup.service # Ver qué pasó journalctl -u mibackup.service -n 20 That\u0026rsquo;s it. Systemd timers isn\u0026rsquo;t a panacea, but for scheduled tasks on modern servers, it\u0026rsquo;s superior to cron in almost every way. Centralized logs in journald, combined with Persistent=true, make it impossible not to recommend it.\nOn my home server, all backups, cache cleanups, and data synchronizations use timers. Zero loose log files. Everything integrated.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Intel N100 Mini PC — Silent and efficient mini PC for 24/7 home server Affiliate links. No extra cost for you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/systemd-timers-la-alternativa-moderna-a-cron-que-necesitabas/","section":"Posts","summary":"Why I Left Cron # I’ve been using cron on my servers for years. It’s simple, reliable, and it works. But recently I discovered systemd timers and I’m not going back. The main reason: integrated logs in journald, no .log files scattered around the system, and better control over what happens when the server starts or reboots.\nIn my specific case, I had a backup that wouldn’t run if the server was off at the scheduled time. With cron, it simply got lost. With systemd timers and Persistent=true, the task runs as soon as the server boots up.\n","title":"Systemd timers: the modern cron alternative you needed","type":"posts"},{"content":" The Problem Nobody Sees # Two months ago I deployed a FastAPI API to production and the server was behaving strangely. CPU stable at 40-50% for no apparent reason. I thought it was a memory leak, that it was the logs, that it was the database. It was --reload.\nIt turns out that copy-pasting the development command directly into the Docker container is more common than it should be. And yes, uvicorn with --reload works. The server responds. Requests go fast. But there\u0026rsquo;s a cost you don\u0026rsquo;t see until you have 10K requests daily.\nWhat exactly is the file-watcher # The --reload flag in uvicorn starts an additional process that monitors all Python files in your project. Not just your code. All of them.\nWhen you activate --reload, uvicorn:\nStarts a process manager (watchfiles by default) Every X seconds (default 0.4s) scans all .py files in the directory Calculates checksums or hashes of each file If it detects changes, restarts the complete worker Meanwhile, keeps scanning on every cycle, even without changes This scanning isn\u0026rsquo;t free. In a medium-sized project with 200 Python files distributed across vendor, libraries, and your own modules, each watchfiles cycle touches disk and CPU.\nHow it consumes CPU on each request # The criminal part is that the file-watcher doesn\u0026rsquo;t pause during requests. While your API is processing a request:\nThe monitor keeps scanning files in the background Competes for disk I/O with your application In containers, if you don\u0026rsquo;t have resource limits, it can consume more CPU than the request logic itself I measured this on my server. With a simple request to an endpoint that takes 50ms:\nWithout --reload:\nCPU: 2-3% por request I/O wait: \u0026lt;1% With --reload:\nCPU: 8-12% por request I/O wait: 3-5% It doesn\u0026rsquo;t look like much in an isolated request. But with 100 concurrent requests, that overhead multiplies.\nHow to detect it on your server # 1. Look at running processes # ps aux | grep uvicorn If you see two uvicorn processes (or uvicorn + watchfiles), you have --reload active.\n2. Check your startup command # # MAL - esto es lo que probablemente tienes uvicorn main:app --host 0.0.0.0 --port 8000 --reload # BIEN - así debe estar en producción uvicorn main:app --host 0.0.0.0 --port 8000 3. Monitor CPU during a load test # # Terminal 1: corre tu servidor docker run -it tu-contenedor # Terminal 2: genera carga ab -n 1000 -c 10 http://localhost:8000/endpoint Watch top or docker stats. If you see unexplained spikes, suspect --reload.\n4. Check uvicorn logs # With --reload active, you\u0026rsquo;ll see messages like:\nINFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) INFO: Will watch for changes in these directories: ... If you see \u0026ldquo;Will watch for changes\u0026rdquo;, you have a problem.\nThe solution (it\u0026rsquo;s obvious, but important) # In Docker, make sure your Dockerfile does NOT use --reload:\nFROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . # Aquí no va --reload CMD [\u0026#34;uvicorn\u0026#34;, \u0026#34;main:app\u0026#34;, \u0026#34;--host\u0026#34;, \u0026#34;0.0.0.0\u0026#34;, \u0026#34;--port\u0026#34;, \u0026#34;8000\u0026#34;, \u0026#34;--workers\u0026#34;, \u0026#34;4\u0026#34;] For local development, use --reload without guilt:\nuvicorn main:app --reload What I learned # The file-watcher in uvicorn is excellent for development. It\u0026rsquo;s transparent, works well, and speeds up your cycle. But in production it\u0026rsquo;s like leaving your computer continuously scanning with antivirus.\nI reviewed my other containers after this. I found three more with --reload active. After removing it, CPU consumption dropped between 30-45%.\nIt\u0026rsquo;s one of those bugs that isn\u0026rsquo;t a bug. Your application works. Requests are processed. But your server is doing invisible work it doesn\u0026rsquo;t need to do.\nNext time before deploying to production, grep for --reload. It will save you a troubleshooting session.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost for you.\n","date":"4 May 2026","externalUrl":null,"permalink":"/en/posts/el-coste-oculto-de---reload-en-uvicorn-que-consume-cpu-realmente-en-produccion/","section":"Posts","summary":"The Problem Nobody Sees # Two months ago I deployed a FastAPI API to production and the server was behaving strangely. CPU stable at 40-50% for no apparent reason. I thought it was a memory leak, that it was the logs, that it was the database. It was --reload.\nIt turns out that copy-pasting the development command directly into the Docker container is more common than it should be. And yes, uvicorn with --reload works. The server responds. Requests go fast. But there’s a cost you don’t see until you have 10K requests daily.\n","title":"The hidden cost of --reload in uvicorn: what actually consumes CPU in production","type":"posts"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/troubleshooting/","section":"Tags","summary":"","title":"Troubleshooting","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/uvicorn/","section":"Tags","summary":"","title":"Uvicorn","type":"tags"},{"content":"","date":"4 May 2026","externalUrl":null,"permalink":"/en/tags/variables-entorno/","section":"Tags","summary":"","title":"Variables-Entorno","type":"tags"},{"content":"En cumplimiento de lo dispuesto en el artículo 10 de la Ley 34/2002, de 11 de julio, de Servicios de la Sociedad de la Información y de Comercio Electrónico (LSSI-CE), se informa de los siguientes datos:\nTitular del sitio web # Nombre: Rogelio Guerra Riverón Email de contacto: serviciosrogeliowar@gmail.com Sitio web: serviciosrogeliowar.com / blog.serviciosrogeliowar.com Objeto y ámbito de aplicación # El presente aviso legal regula el uso del sitio web serviciosrogeliowar.com y blog.serviciosrogeliowar.com (en adelante, \u0026ldquo;el Sitio\u0026rdquo;), del que es titular Rogelio Guerra Riverón.\nLa utilización del Sitio implica la aceptación plena y sin reservas de todas las disposiciones incluidas en este Aviso Legal. El titular se reserva el derecho a modificar el presente aviso legal en cualquier momento, siendo responsabilidad del usuario su consulta periódica.\nPropiedad intelectual e industrial # Los contenidos del Sitio —incluyendo textos, imágenes, código fuente y diseño— son propiedad del titular o de sus legítimos licenciantes, y están protegidos por las leyes españolas e internacionales de propiedad intelectual e industrial.\nQueda expresamente prohibida la reproducción, distribución, comunicación pública o transformación de los contenidos sin autorización expresa del titular, salvo que se indique expresamente lo contrario o se trate de usos permitidos por la ley.\nResponsabilidad # El titular no garantiza la ausencia de errores en los contenidos ni su actualización permanente. El acceso al Sitio y el uso de la información contenida en él es responsabilidad exclusiva del usuario.\nEl titular no será responsable de los daños o perjuicios que pudieran derivarse del acceso al Sitio o del uso de la información que en él se contiene.\nPolítica de enlaces # El Sitio puede contener enlaces a páginas web de terceros. El titular no asume ninguna responsabilidad sobre los contenidos, informaciones o servicios que aparezcan en dichos sitios.\nLegislación aplicable y jurisdicción # El presente Aviso Legal se rige por la legislación española vigente. Para la resolución de cualquier controversia que pudiera surgir en relación con el Sitio, las partes se someten a la jurisdicción de los Juzgados y Tribunales competentes conforme a derecho.\nÚltima actualización: mayo de 2026\n","date":"May 1, 2026","externalUrl":null,"permalink":"/legal/aviso-legal/","section":"Legals","summary":"En cumplimiento de lo dispuesto en el artículo 10 de la Ley 34/2002, de 11 de julio, de Servicios de la Sociedad de la Información y de Comercio Electrónico (LSSI-CE), se informa de los siguientes datos:\nTitular del sitio web # Nombre: Rogelio Guerra Riverón Email de contacto: serviciosrogeliowar@gmail.com Sitio web: serviciosrogeliowar.com / blog.serviciosrogeliowar.com Objeto y ámbito de aplicación # El presente aviso legal regula el uso del sitio web serviciosrogeliowar.com y blog.serviciosrogeliowar.com (en adelante, “el Sitio”), del que es titular Rogelio Guerra Riverón.\n","title":"Aviso Legal","type":"legal"},{"content":" Server Security Hardening: Essential Steps # Having a server exposed to the Internet without minimal security configuration is leaving the door wide open. In this article, I\u0026rsquo;ve compiled the steps I apply on my own servers to reduce the attack surface without complicating day-to-day management.\nSSH: the first line of defense # The SSH service is the most attacked entry point on any Linux server. These are the most important settings in /etc/ssh/sshd_config:\n# Deshabilitar acceso root PermitRootLogin no # Solo autenticación por clave pública PasswordAuthentication no ChallengeResponseAuthentication no # Limitar intentos de autenticación MaxAuthTries 3 # Desactivar forwarding innecesario X11Forwarding no AllowTcpForwarding no After each change:\nsudo systemctl reload ssh # Ubuntu/Debian sudo systemctl reload sshd # CentOS/RHEL Why PermitRootLogin no and not prohibit-password? Although prohibit-password already blocks root access by password, leaving root login by key enabled is still a risk: if that key is compromised, the attacker has full system access without needing to escalate privileges.\nChanging the SSH port (security through obscurity) # Changing the default port (22) is not real security, but it eliminates the noise from internet bots that continuously scan that port:\nPort 2222 # cualquier número entre 1024 y 65535 On the router or firewall, create the NAT rule to redirect the external port to internal port 22, or configure UFW to accept the new port.\nFirewall with UFW # UFW (Uncomplicated Firewall) simplifies iptables management:\n# Política por defecto: denegar todo sudo ufw default deny incoming sudo ufw default allow outgoing # Permitir solo lo necesario sudo ufw allow 2222/tcp # SSH en puerto personalizado sudo ufw allow 80/tcp # HTTP sudo ufw allow 443/tcp # HTTPS # Activar sudo ufw enable sudo ufw status verbose Automatic security updates # Servers that don\u0026rsquo;t get updated eventually become vulnerable. unattended-upgrades applies security patches without manual intervention:\nsudo apt install unattended-upgrades sudo dpkg-reconfigure unattended-upgrades To verify it\u0026rsquo;s active:\nsystemctl is-active unattended-upgrades The configuration file is located at /etc/apt/apt.conf.d/50unattended-upgrades. By default it only applies security updates, which is the correct behavior for production.\nUser management # Principle of least privilege # Each user should only have the permissions they need. Regularly review which users have an active shell:\ngrep -v nologin /etc/passwd | grep -v false To disable a user without deleting them:\nsudo usermod -s /usr/sbin/nologin usuario sudo passwd -l usuario # Bloquear contraseña sudo without password — thoughtfully # It\u0026rsquo;s tempting to put NOPASSWD in sudoers to avoid typing the password, but limit it to the specific commands that need it:\n# Bien: solo para comandos concretos usuario ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/systemctl restart nginx # Mal: acceso total sin contraseña usuario ALL=(ALL) NOPASSWD: ALL Brute force protection with fail2ban # fail2ban monitors system logs and automatically blocks IPs that make too many failed attempts:\nsudo apt install fail2ban Basic configuration in /etc/fail2ban/jail.local:\n[DEFAULT] bantime = 1h findtime = 10m maxretry = 5 [sshd] enabled = true port = 2222 logpath = %(sshd_log)s backend = %(sshd_backend)s sudo systemctl enable fail2ban sudo systemctl start fail2ban # Ver IPs baneadas sudo fail2ban-client status sshd Auditing: what to review periodically # Once the server is configured, it\u0026rsquo;s good to check these points regularly:\n# Intentos de acceso fallidos sudo lastb | head -20 # Puertos escuchando (detectar servicios inesperados) ss -tlnp # Binarios con SUID (posibles vectores de escalada de privilegios) find / -perm -4000 -type f 2\u0026gt;/dev/null # Actualizaciones pendientes apt list --upgradable 2\u0026gt;/dev/null | grep -i security Encryption between servers with WireGuard # If you have multiple servers that need to communicate (rsync, databases, internal APIs), avoid exposing those services to the Internet. WireGuard offers a fast and simple VPN tunnel with ChaCha20-Poly1305 encryption:\nsudo apt install wireguard wg genkey | tee privatekey | wg pubkey \u0026gt; publickey Traffic between servers travels encrypted through the tunnel (10.10.0.x) instead of using public IPs. This way, services like rsync or PostgreSQL never get exposed even if someone intercepts network traffic.\nI have a specific article on emergency replication with rsync and WireGuard with the complete configuration.\nSummary: hardening checklist # Action Priority PermitRootLogin no in sshd_config High PasswordAuthentication no High UFW Firewall active with minimal rules High unattended-upgrades active High Change SSH port Medium AllowTcpForwarding no Medium MaxAuthTries 3 Medium fail2ban installed and configured Medium Review users with active shell Medium Periodic audit of ports and SUID Low Perfect security does not exist, but applying these steps drastically reduces the probability of being the easy target that automated bots are looking for.\nRecommended Equipment # Intel N100 Mini PC — Silent and efficient mini PC for 24/7 home server YubiKey 5 NFC — Physical security key for 2FA and secure SSH access Affiliate links. No extra cost for you.\n","date":"1 May 2026","externalUrl":null,"permalink":"/en/posts/hardening-servidores-linux/","section":"Posts","summary":"Server Security Hardening: Essential Steps # Having a server exposed to the Internet without minimal security configuration is leaving the door wide open. In this article, I’ve compiled the steps I apply on my own servers to reduce the attack surface without complicating day-to-day management.\nSSH: the first line of defense # The SSH service is the most attacked entry point on any Linux server. These are the most important settings in /etc/ssh/sshd_config:\n","title":"Hardening Linux servers: practical guide","type":"posts"},{"content":"","date":"May 1, 2026","externalUrl":null,"permalink":"/legal/","section":"Legals","summary":"","title":"Legals","type":"legal"},{"content":"En cumplimiento del artículo 22.2 de la Ley 34/2002, de Servicios de la Sociedad de la Información (LSSI-CE) y el Reglamento (UE) 2016/679 (RGPD), te informamos sobre las cookies utilizadas en este sitio web.\n¿Qué son las cookies? # Las cookies son pequeños archivos de texto que los sitios web almacenan en tu dispositivo cuando los visitas. Sirven para recordar tus preferencias, mejorar la experiencia de navegación y, en algunos casos, recopilar información sobre el uso del sitio.\nCookies que utilizamos # Cookies necesarias # Son imprescindibles para el funcionamiento básico del sitio. No requieren tu consentimiento y no pueden desactivarse.\nCookie Proveedor Finalidad Duración cookie_consent Este sitio Almacena tus preferencias de cookies 1 año Cookies funcionales # Mejoran la funcionalidad del sitio. Solo se activan si das tu consentimiento.\nCookie Proveedor Finalidad Duración _gh_sess, user_session GitHub (Giscus) Sistema de comentarios y reacciones Sesión onesignal-* OneSignal Gestión de notificaciones push Persistente Sin cookies de analítica ni publicidad: Este sitio no utiliza Google Analytics ni ninguna otra herramienta de análisis de comportamiento. No existen cookies publicitarias.\nGestionar tus preferencias # Puedes modificar tus preferencias de cookies en cualquier momento haciendo clic aquí:\nTambién puedes controlar las cookies desde la configuración de tu navegador:\nGoogle Chrome Mozilla Firefox Safari Microsoft Edge Más información # Para información sobre cómo tratamos tus datos personales, consulta nuestra Política de Privacidad.\nÚltima actualización: mayo de 2026\n","date":"May 1, 2026","externalUrl":null,"permalink":"/legal/politica-cookies/","section":"Legals","summary":"En cumplimiento del artículo 22.2 de la Ley 34/2002, de Servicios de la Sociedad de la Información (LSSI-CE) y el Reglamento (UE) 2016/679 (RGPD), te informamos sobre las cookies utilizadas en este sitio web.\n¿Qué son las cookies? # Las cookies son pequeños archivos de texto que los sitios web almacenan en tu dispositivo cuando los visitas. Sirven para recordar tus preferencias, mejorar la experiencia de navegación y, en algunos casos, recopilar información sobre el uso del sitio.\n","title":"Política de Cookies","type":"legal"},{"content":"En cumplimiento del Reglamento (UE) 2016/679 General de Protección de Datos (RGPD) y la Ley Orgánica 3/2018, de 5 de diciembre, de Protección de Datos Personales y garantía de los derechos digitales (LOPD-GDD), se informa de lo siguiente:\nResponsable del tratamiento # Nombre: Rogelio Guerra Riverón Email: serviciosrogeliowar@gmail.com Sitio web: serviciosrogeliowar.com / blog.serviciosrogeliowar.com Datos que recopilamos y finalidades # Suscripción al newsletter # Cuando te suscribes al newsletter a través del formulario del blog, recopilamos:\nNombre (opcional) Dirección de correo electrónico Finalidad: Envío de comunicaciones informativas sobre nuevos artículos publicados en el blog.\nBase legal: Consentimiento del interesado (art. 6.1.a RGPD).\nPlazo de conservación: Hasta que el suscriptor solicite la baja o retire su consentimiento.\nComentarios y reacciones (Giscus) # El sistema de comentarios y reacciones utiliza Giscus, basado en GitHub Discussions. Si interactúas con los comentarios, deberás disponer de una cuenta de GitHub. El tratamiento de datos en este contexto está sujeto a la Política de privacidad de GitHub.\nNotificaciones push (OneSignal) # Si aceptas las notificaciones push, OneSignal almacena un identificador de dispositivo para el envío de notificaciones. El tratamiento está sujeto a la Política de privacidad de OneSignal.\nCookies # Consulta nuestra Política de Cookies para información detallada.\nDerechos del interesado # Puedes ejercer en cualquier momento los siguientes derechos:\nAcceso: Obtener confirmación sobre si tratamos tus datos y, en su caso, acceder a ellos. Rectificación: Solicitar la corrección de datos inexactos. Supresión: Solicitar la eliminación de tus datos cuando ya no sean necesarios. Oposición: Oponerte al tratamiento de tus datos. Limitación: Solicitar la restricción del tratamiento. Portabilidad: Recibir tus datos en formato estructurado y de uso común. Retirada del consentimiento: En cualquier momento, sin que ello afecte a la licitud del tratamiento previo. Para ejercer estos derechos, envía un email a serviciosrogeliowar@gmail.com indicando el derecho que deseas ejercer y adjuntando una copia de tu documento de identidad.\nTienes derecho a presentar una reclamación ante la Agencia Española de Protección de Datos (AEPD) en www.aepd.es si consideras que el tratamiento no es conforme al RGPD.\nTransferencias internacionales # OneSignal y GitHub (Giscus) son empresas estadounidenses que pueden transferir datos fuera del Espacio Económico Europeo. Estas transferencias se realizan bajo garantías adecuadas conforme al RGPD (cláusulas contractuales tipo o marcos de adecuación).\nSeguridad # Aplicamos medidas técnicas y organizativas adecuadas para garantizar la seguridad de los datos personales, incluyendo cifrado TLS en todas las comunicaciones y alojamiento en servidores bajo control del responsable.\nÚltima actualización: mayo de 2026\n","date":"May 1, 2026","externalUrl":null,"permalink":"/legal/politica-privacidad/","section":"Legals","summary":"En cumplimiento del Reglamento (UE) 2016/679 General de Protección de Datos (RGPD) y la Ley Orgánica 3/2018, de 5 de diciembre, de Protección de Datos Personales y garantía de los derechos digitales (LOPD-GDD), se informa de lo siguiente:\nResponsable del tratamiento # Nombre: Rogelio Guerra Riverón Email: serviciosrogeliowar@gmail.com Sitio web: serviciosrogeliowar.com / blog.serviciosrogeliowar.com Datos que recopilamos y finalidades # Suscripción al newsletter # Cuando te suscribes al newsletter a través del formulario del blog, recopilamos:\n","title":"Política de Privacidad","type":"legal"},{"content":"","date":"1 May 2026","externalUrl":null,"permalink":"/en/tags/vps/","section":"Tags","summary":"","title":"Vps","type":"tags"},{"content":" Why Automate Content Generation # Writing technical articles takes time. Between server configuration, troubleshooting, and documentation, I find little time to write. So I decided to create an AI agent to help me structure and generate drafts from the terminal.\nClaude Haiku is perfect for this: it\u0026rsquo;s fast, cheap, and works well for text generation tasks. It doesn\u0026rsquo;t require powerful GPUs. I simply run a script and have an article ready to edit.\nRequirements # Anthropic account with access to Claude API API token configured in an environment variable Python 3.10+ anthropic library installed pip install anthropic The Agent in Practice # I created a script that receives as input:\nA topic or concept to document The desired number of sections The tone (technical, educational, etc.) And it generates a Markdown article ready to publish.\nImplementation # Here\u0026rsquo;s the base script I use:\n#!/usr/bin/env python3 import anthropic import sys from datetime import datetime def generate_article(topic: str, sections: int = 5, tone: str = \u0026#34;técnico\u0026#34;) -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34; Genera un artículo de blog usando Claude Haiku \u0026#34;\u0026#34;\u0026#34; client = anthropic.Anthropic() prompt = f\u0026#34;\u0026#34;\u0026#34;Eres un escritor técnico especializado en servidores, Docker y automatización. Genera un artículo de blog sobre: {topic} Requisitos: - Número de secciones principales: {sections} - Tono: {tone} - Incluye ejemplos de código cuando sea relevante - Usa formato Markdown - Longitud: 600-800 palabras - Sé práctico y directo, sin florituras - Primera persona cuando sea apropiado Estructura recomendada: 1. Introducción (por qué es importante) 2. Requisitos previos 3. Pasos o explicación principal (puede dividirse en subsecciones) 4. Configuración o ejemplo práctico 5. Conclusión Genera el artículo completo en Markdown, listo para publicar.\u0026#34;\u0026#34;\u0026#34; message = client.messages.create( model=\u0026#34;claude-3-5-haiku-20241022\u0026#34;, max_tokens=2000, messages=[ {\u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: prompt} ] ) return message.content[0].text def save_article(content: str, filename: str) -\u0026gt; None: \u0026#34;\u0026#34;\u0026#34; Guarda el artículo en un archivo con frontmatter \u0026#34;\u0026#34;\u0026#34; frontmatter = f\u0026#34;\u0026#34;\u0026#34;--- title: \u0026#34;{filename.replace(\u0026#39;-\u0026#39;, \u0026#39; \u0026#39;).title()}\u0026#34; date: {datetime.now().strftime(\u0026#39;%Y-%m-%d\u0026#39;)} draft: false tags: [\u0026#34;técnica\u0026#34;, \u0026#34;servidor\u0026#34;, \u0026#34;automatización\u0026#34;] description: \u0026#34;Artículo generado con asistencia de IA\u0026#34; --- \u0026#34;\u0026#34;\u0026#34; with open(f\u0026#34;{filename}.md\u0026#34;, \u0026#34;w\u0026#34;, encoding=\u0026#34;utf-8\u0026#34;) as f: f.write(frontmatter + content) print(f\u0026#34;✓ Artículo guardado en {filename}.md\u0026#34;) def main(): if len(sys.argv) \u0026lt; 2: print(\u0026#34;Uso: python blog_agent.py \u0026#39;\u0026lt;tema\u0026gt;\u0026#39; [secciones] [tono]\u0026#34;) print(\u0026#34;Ejemplo: python blog_agent.py \u0026#39;Docker en producción\u0026#39; 5 técnico\u0026#34;) sys.exit(1) topic = sys.argv[1] sections = int(sys.argv[2]) if len(sys.argv) \u0026gt; 2 else 5 tone = sys.argv[3] if len(sys.argv) \u0026gt; 3 else \u0026#34;técnico\u0026#34; print(f\u0026#34;Generando artículo sobre: {topic}...\u0026#34;) print(\u0026#34;Esto puede tomar 30-60 segundos...\\n\u0026#34;) content = generate_article(topic, sections, tone) filename = topic.lower().replace(\u0026#34; \u0026#34;, \u0026#34;-\u0026#34;).replace(\u0026#34;\u0026#39;\u0026#34;, \u0026#34;\u0026#34;) save_article(content, filename) print(\u0026#34;\\nPrimeras líneas del artículo:\u0026#34;) print(\u0026#34;-\u0026#34; * 50) print(content[:300] + \u0026#34;...\\n\u0026#34;) if __name__ == \u0026#34;__main__\u0026#34;: main() Using it in Practice # I run the script like this:\npython blog_agent.py \u0026#39;Configurar Nginx con SSL en Docker\u0026#39; 5 técnico In less than a minute I have a .md file with a complete draft. Then I review it, correct specific details, and publish it.\nReal Advantages # Speed: From topic to draft in 1-2 minutes Consistency: The format is always coherent Starting point: I don\u0026rsquo;t start from a blank page Economical: Haiku is very cheap compared to other models Limitations # The agent generates generic content. I always need to add:\nSpecific details from my actual configurations Exact commands I used Errors I faced and how I resolved them My personal perspective It\u0026rsquo;s an assistant, not a replacement. But it saves a lot of time on structure and initial writing.\nConclusion # Using AI to automate technical writing makes sense if you combine it with human editing. This agent allows me to document experiences faster without sacrificing quality. If you write frequently on a technical blog, it\u0026rsquo;s worth experimenting.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/agente-ia-con-claude-haiku-para-generar-articulos-de-blog-desde-el-terminal/","section":"Posts","summary":"Why Automate Content Generation # Writing technical articles takes time. Between server configuration, troubleshooting, and documentation, I find little time to write. So I decided to create an AI agent to help me structure and generate drafts from the terminal.\nClaude Haiku is perfect for this: it’s fast, cheap, and works well for text generation tasks. It doesn’t require powerful GPUs. I simply run a script and have an article ready to edit.\n","title":"AI Agent with Claude Haiku to Generate Blog Articles from the Terminal","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/autenticaci%C3%B3n/","section":"Tags","summary":"","title":"Autenticación","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/backup/","section":"Tags","summary":"","title":"Backup","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/blog/","section":"Tags","summary":"","title":"Blog","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/blowfish/","section":"Tags","summary":"","title":"Blowfish","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/ci/cd/","section":"Tags","summary":"","title":"Ci/Cd","type":"tags"},{"content":" Introduction # Tired of manually deploying my Hugo blog every time I publish an article. I decided to set up a local CI/CD pipeline with GitLab Runner. The result: automatic, reliable, and without depending on external services.\nPrerequisites # You need:\nA server with Docker installed A repository on GitLab (can be self-hosted or gitlab.com) Hugo installed locally for testing SSH access configured on your server Installing GitLab Runner # First, install GitLab Runner on your server. I did it on Docker because I already had the daemon running.\ndocker pull gitlab/gitlab-runner:latest docker run -d --name gitlab-runner \\ --restart always \\ -v /var/run/docker.sock:/var/run/docker.sock \\ -v /srv/gitlab-runner/config:/etc/gitlab-runner \\ gitlab/gitlab-runner:latest This mounts the Docker socket so the runner can execute nested containers. Important for building images.\nRegistering the Runner # You need a token from your GitLab project. You can find it at: Configuración del proyecto → CI/CD → Runners\nThen you run:\ndocker exec -it gitlab-runner gitlab-runner register \\ --url https://gitlab.com/ \\ --registration-token TU_TOKEN_AQUI \\ --executor docker \\ --docker-image alpine:latest \\ --docker-volumes /var/run/docker.sock:/var/run/docker.sock \\ --description \u0026#34;Runner Local Hugo\u0026#34; Choose Docker as the executor. It\u0026rsquo;s the cleanest option for this case.\nConfiguring the pipeline # At the root of your repository, create .gitlab-ci.yml:\nstages: - build - deploy variables: DEPLOY_PATH: /home/deploy/blog-hugo/public build: stage: build image: alpine:latest before_script: - apk add --no-cache hugo git script: - hugo --minify - echo \u0026#34;Build completado\u0026#34; artifacts: paths: - public/ expire_in: 1 week only: - main deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo \u0026#34;$DEPLOY_KEY\u0026#34; | base64 -d \u0026gt; ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H localhost \u0026gt;\u0026gt; ~/.ssh/known_hosts script: - rsync -avz --delete public/ deploy@localhost:${DEPLOY_PATH} - ssh deploy@localhost \u0026#39;sudo systemctl restart nginx\u0026#39; only: - main when: on_success Environment variables # In Configuración del proyecto → CI/CD → Variables, add:\nDEPLOY_KEY: Your private SSH key in base64 (cat ~/.ssh/id_rsa | base64 -w0) DEPLOY_PATH: Path where you want the files (I use /home/deploy/blog-hugo/public) Deploy user # On your server, create a specific user:\nsudo useradd -m -s /bin/bash deploy sudo usermod -aG docker deploy sudo mkdir -p /home/deploy/blog-hugo/public sudo chown deploy:deploy /home/deploy/blog-hugo Configure the runner\u0026rsquo;s public SSH key:\nsudo -u deploy ssh-keygen -t ed25519 -N \u0026#34;\u0026#34; -f /home/deploy/.ssh/id_rsa cat /home/deploy/.ssh/id_rsa.pub \u0026gt;\u0026gt; /home/deploy/.ssh/authorized_keys Verify it works # Push to the main branch:\ngit add . git commit -m \u0026#34;Test CI/CD\u0026#34; git push origin main In GitLab you see the pipeline in real time. If everything is fine, in seconds your blog will be deployed.\nsudo -u deploy cat /home/deploy/blog-hugo/public/index.html Final notes # The local runner never leaves your network. Full control. Build times are fast because everything is on the local machine. If you need to cache dependencies, configure persistent volumes in Docker. I\u0026rsquo;ve put restrictions on the main branch to avoid accidental deployments. After three months running without issues. It\u0026rsquo;s simple but effective.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect base for homelab, Docker, and monitoring Affiliate links. No extra cost for you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/cicd-con-gitlab-runner-local-para-desplegar-automaticamente-un-blog-hugo/","section":"Posts","summary":"Introduction # Tired of manually deploying my Hugo blog every time I publish an article. I decided to set up a local CI/CD pipeline with GitLab Runner. The result: automatic, reliable, and without depending on external services.\nPrerequisites # You need:\nA server with Docker installed A repository on GitLab (can be self-hosted or gitlab.com) Hugo installed locally for testing SSH access configured on your server Installing GitLab Runner # First, install GitLab Runner on your server. I did it on Docker because I already had the daemon running.\n","title":"CI/CD with local GitLab Runner to automatically deploy a Hugo blog","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/claude/","section":"Tags","summary":"","title":"Claude","type":"tags"},{"content":"A server without monitoring is a blind server. You don\u0026rsquo;t know when the disk fills up, which container is consuming too much RAM, or how many 404 requests your web is generating. This article documents how I configured the complete stack: Prometheus + Node Exporter + Grafana + Loki + Promtail.\nThe architecture # [Servidor doméstico] ├── node-exporter → métricas del sistema (CPU, RAM, disco, red) ├── docker-stats- → métricas de contenedores (textfile collector) │ collector ├── prometheus → recolecta y almacena métricas ├── loki → agrega y almacena logs ├── promtail → envía logs de Nginx y syslog a Loki └── grafana → dashboards de todo lo anterior All services run in Docker, coordinated by the same docker-compose.yml.\nSystem metrics: Node Exporter # Node Exporter exposes hardware and OS metrics. The trick: it has to run with network_mode: host to see the actual server network interfaces. If it runs on Docker network, it only sees the container\u0026rsquo;s eth0 interface.\nnode-exporter: image: prom/node-exporter:v1.8.2 network_mode: host pid: host volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro - ./textfile-collector:/textfile:ro command: - --path.procfs=/host/proc - --path.sysfs=/host/sys - --path.rootfs=/rootfs - --web.listen-address=127.0.0.1:9100 - --collector.textfile.directory=/textfile It listens on 127.0.0.1:9100. Prometheus reaches it via 172.17.0.1:9100 (the host\u0026rsquo;s IP from the Docker network).\nContainer metrics: docker stats + textfile collector # The problem with cAdvisor is that it doesn\u0026rsquo;t work with Docker 29 and the overlayfs storage driver on cgroupv2 — it fails with \u0026ldquo;failed to identify read-write layer ID\u0026rdquo;.\nThe solution: a lightweight container that runs docker stats every 30 seconds and writes the result in Prometheus format to a file that Node Exporter reads.\n#!/bin/bash # docker_stats.sh OUTFILE=\u0026#34;/textfile/docker_stats.prom\u0026#34; TMPFILE=\u0026#34;${OUTFILE}.tmp\u0026#34; { echo \u0026#34;# HELP docker_container_cpu_percent CPU usage percentage per container\u0026#34; echo \u0026#34;# TYPE docker_container_cpu_percent gauge\u0026#34; # ... más definiciones ... docker stats --no-stream --format \\ \u0026#39;{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.NetIO}}\u0026#39; 2\u0026gt;/dev/null | \\ while IFS=\u0026#39;|\u0026#39; read -r name cpu mem net; do cpu_val=$(echo \u0026#34;$cpu\u0026#34; | tr -d \u0026#39;%\u0026#39; | tr \u0026#39;,\u0026#39; \u0026#39;.\u0026#39;) # ... conversión de unidades ... echo \u0026#34;docker_container_cpu_percent{name=\\\u0026#34;${name}\\\u0026#34;} ${cpu_val}\u0026#34; echo \u0026#34;docker_container_memory_bytes{name=\\\u0026#34;${name}\\\u0026#34;} ${mem_used_bytes}\u0026#34; echo \u0026#34;docker_container_running{name=\\\u0026#34;${name}\\\u0026#34;} 1\u0026#34; done # Contenedores parados docker ps -a --filter \u0026#34;status=exited\u0026#34; --format \u0026#39;{{.Names}}\u0026#39; 2\u0026gt;/dev/null | \\ while read -r name; do echo \u0026#34;docker_container_running{name=\\\u0026#34;${name}\\\u0026#34;} 0\u0026#34; done } \u0026gt; \u0026#34;$TMPFILE\u0026#34; \u0026amp;\u0026amp; mv \u0026#34;$TMPFILE\u0026#34; \u0026#34;$OUTFILE\u0026#34; Atomic writes (tmp → final) prevent Prometheus from reading a partially-written file.\ndocker-stats-collector: image: docker:27-cli volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./textfile-collector:/textfile - ./docker_stats.sh:/docker_stats.sh:ro entrypoint: sh -c \u0026#34;apk add --no-cache bc \u0026gt; /dev/null 2\u0026gt;\u0026amp;1; while true; do sh /docker_stats.sh; sleep 30; done\u0026#34; Prometheus: collect and retain # prometheus: image: prom/prometheus:v2.51.2 networks: - monitoring volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus - --storage.tsdb.retention.time=30d - --web.enable-lifecycle Scraping configuration:\nglobal: scrape_interval: 15s scrape_configs: - job_name: prometheus static_configs: - targets: [localhost:9090] - job_name: node static_configs: - targets: [172.17.0.1:9100] relabel_configs: - target_label: host replacement: servidor-casa 172.17.0.1 is the host\u0026rsquo;s IP accessible from the Docker bridge network. Data is retained for 30 days.\nLogs: Loki + Promtail # Loki stores logs without indexing the full content — only the labels. Promtail collects them and sends them with labels like job, host, filename.\npromtail: image: grafana/promtail:3.3.2 user: root networks: - monitoring volumes: - ./promtail-config.yml:/etc/promtail/config.yml:ro - ./promtail-data:/tmp/promtail - ~/infra/web/logs:/logs/nginx:ro - /var/log:/logs/host:ro It needs to run as root to read /var/log.\nGrafana: dashboards # Grafana connects to Prometheus and Loki as data sources. The most useful dashboards:\nSystem (Node Exporter):\nTotal CPU and per-core RAM used / free / cache Disk: usage per partition, IOPS, throughput Network: inbound/outbound traffic per interface Containers (docker stats):\nCPU % per container RAM per container vs limit State (running/stopped) Network traffic per container Logs (Loki):\nNginx logs in real-time Requests by status code (200, 301, 404, 500) Top IPs with most requests Top most-accessed routes Issue: [$__range] in Loki instant queries # When using \u0026ldquo;stat\u0026rdquo; or \u0026ldquo;piechart\u0026rdquo; panels with Loki, the [$__range] variable doesn\u0026rsquo;t resolve — Grafana returns \u0026ldquo;empty duration string\u0026rdquo;. The solution is to use a fixed duration:\n# MAL (en paneles stat/piechart): sum by(status) (count_over_time({job=\u0026#34;nginx\u0026#34;} | pattern ... [$__range])) # BIEN: sum by(status) (count_over_time({job=\u0026#34;nginx\u0026#34;} | pattern ... [24h])) \u0026ldquo;Time series\u0026rdquo; panels do support [$__interval] correctly.\nStack security # Prometheus and Loki have no external access — only on the internal monitoring network Grafana is the only access point, protected with Traefik and Let\u0026rsquo;s Encrypt GF_AUTH_ANONYMOUS_ENABLED=false and GF_USERS_ALLOW_SIGN_UP=false in Grafana Node Exporter listens only on 127.0.0.1, not exposed on all interfaces Result # With this stack you have complete visibility of the server: which processes consume resources, which containers fail, which requests your web receives and what errors it generates. Everything in dashboards accessible from monitor.serviciosrogeliowar.com.\nRecommended Equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/monitoring-prometheus-grafana-loki/","section":"Posts","summary":"A server without monitoring is a blind server. You don’t know when the disk fills up, which container is consuming too much RAM, or how many 404 requests your web is generating. This article documents how I configured the complete stack: Prometheus + Node Exporter + Grafana + Loki + Promtail.\nThe architecture # [Servidor doméstico] ├── node-exporter → métricas del sistema (CPU, RAM, disco, red) ├── docker-stats- → métricas de contenedores (textfile collector) │ collector ├── prometheus → recolecta y almacena métricas ├── loki → agrega y almacena logs ├── promtail → envía logs de Nginx y syslog a Loki └── grafana → dashboards de todo lo anterior All services run in Docker, coordinated by the same docker-compose.yml.\n","title":"Complete monitoring with Prometheus, Grafana and Loki: metrics, logs and Docker containers","type":"posts"},{"content":" Introduction # After months of using nginx manually, I decided to switch to Traefik. The reason is simple: managing SSL certificates for each new service is tedious. Traefik automates all of that with integrated Let\u0026rsquo;s Encrypt. Here\u0026rsquo;s my actual configuration.\nWhy Traefik # With Traefik you don\u0026rsquo;t need to reload nginx every time you add a container. It automatically detects Docker services, generates SSL certificates on demand, and redirects traffic. All declarative.\nBase structure # I create a folder for Traefik:\nmkdir -p /home/usuario/docker/traefik cd /home/usuario/docker/traefik I need three files: docker-compose.yml, traefik.yml and acme.json.\nacme.json file # This file stores the certificates. It must have restrictive permissions:\ntouch acme.json chmod 600 acme.json Traefik configuration (traefik.yml) # api: insecure: true dashboard: true entryPoints: web: address: \u0026#34;:80\u0026#34; http: redirections: entrypoint: to: websecure scheme: https websecure: address: \u0026#34;:443\u0026#34; providers: docker: endpoint: \u0026#34;unix:///var/run/docker.sock\u0026#34; exposedByDefault: false file: filename: /traefik/traefik.yml watch: true certificatesResolvers: letsencrypt: acme: email: mi-email@example.com storage: acme.json httpChallenge: entryPoint: web I change mi-email@example.com to my actual email. Let\u0026rsquo;s Encrypt uses it for notifications.\nDocker Compose # This is the file that starts everything:\nversion: \u0026#39;3.8\u0026#39; services: traefik: image: traefik:v2.11 container_name: traefik restart: unless-stopped security_opt: - no-new-privileges:true networks: - proxy ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; environment: - TZ=Europe/Madrid volumes: - /etc/localtime:/etc/localtime:ro - /var/run/docker.sock:/var/run/docker.sock:ro - ./traefik.yml:/traefik/traefik.yml:ro - ./acme.json:/acme.json labels: - \u0026#34;traefik.enable=true\u0026#34; - \u0026#34;traefik.http.routers.traefik.rule=Host(`traefik.midominio.com`)\u0026#34; - \u0026#34;traefik.http.routers.traefik.entrypoints=websecure\u0026#34; - \u0026#34;traefik.http.routers.traefik.tls.certresolver=letsencrypt\u0026#34; - \u0026#34;traefik.http.services.traefik.loadbalancer.server.port=8080\u0026#34; networks: proxy: driver: bridge I change traefik.midominio.com to my actual domain. Port 8080 is the Traefik dashboard port.\nStarting Traefik # docker-compose up -d I check the logs:\ndocker-compose logs -f If everything goes well, the dashboard will be at https://traefik.midominio.com.\nAdding services # Here\u0026rsquo;s the good part. To add a new service, I only need labels in Docker. Example with a simple container:\nservices: mi-app: image: mi-imagen:latest container_name: mi-app restart: unless-stopped networks: - proxy labels: - \u0026#34;traefik.enable=true\u0026#34; - \u0026#34;traefik.http.routers.mi-app.rule=Host(`app.midominio.com`)\u0026#34; - \u0026#34;traefik.http.routers.mi-app.entrypoints=websecure\u0026#34; - \u0026#34;traefik.http.routers.mi-app.tls.certresolver=letsencrypt\u0026#34; - \u0026#34;traefik.http.services.mi-app.loadbalancer.server.port=3000\u0026#34; networks: proxy: external: true I don\u0026rsquo;t need to touch Traefik. The certificate is generated automatically.\nCommon issues # The domain doesn\u0026rsquo;t resolve: Make sure your DNS points to the correct IP address.\nACME challenge fails: Verify that port 80 is open and accessible from the internet. Let\u0026rsquo;s Encrypt needs it.\nDashboard slow: It\u0026rsquo;s normal with many services. It\u0026rsquo;s not a problem.\nConclusion # Traefik saved me hours of manual configuration. Each new container only needs four labels. The certificates renew themselves 30 days before they expire.\nIf you have a home server with several services, it\u0026rsquo;s worth migrating. The learning curve is short and the benefits are real.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/configurar-traefik-v211-como-reverse-proxy-con-docker-y-https-automatico-con-lets-encrypt/","section":"Posts","summary":"Introduction # After months of using nginx manually, I decided to switch to Traefik. The reason is simple: managing SSL certificates for each new service is tedious. Traefik automates all of that with integrated Let’s Encrypt. Here’s my actual configuration.\nWhy Traefik # With Traefik you don’t need to reload nginx every time you add a container. It automatically detects Docker services, generates SSL certificates on demand, and redirects traffic. All declarative.\n","title":"Configure Traefik v2.11 as a reverse proxy with Docker and automatic HTTPS with Let's Encrypt","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/css/","section":"Tags","summary":"","title":"Css","type":"tags"},{"content":"CSS seems simple until something doesn\u0026rsquo;t work as you expect. You spend hours staring at the browser inspector, change one property, then another, and the problem persists. This article collects the most frequent errors I\u0026rsquo;ve encountered building this blog, with direct solutions.\nThe background that disappears when scrolling # The problem: The page looks good in the visible area, but when you scroll down a white or unexpected background appears.\nWhy it happens: The background color is defined in an inner container (like main or article), not in html or body. When the content is shorter than the window, the rest of the page is left without color.\nThe solution:\n/* MAL: solo el body tiene color */ body { background-color: #0f172a; } /* BIEN: también html, para cubrir toda la página */ html, body { background-color: #0f172a; min-height: 100%; } This guarantees that the background covers from the first pixel to the last, regardless of how much content there is.\nDark mode that only applies partially # The problem: You activate dark mode and some elements change, but others remain with a light background.\nWhy it happens: Dark mode in frameworks like Tailwind or Blowfish works by adding the .dark class to the html element. If your custom CSS only targets body, it may lose color in certain cases.\n/* Puede fallar en algunos navegadores */ html.dark body { background-color: #0f172a; } /* Más robusto: apuntar también a html */ html.dark, html.dark body { background-color: #0f172a; } Practical rule: when working with class-based dark mode, always apply the background color to both html and body.\nbackground-image without background-color # The problem: You have an SVG pattern or background image, but in areas where the image doesn\u0026rsquo;t load or takes time, a white background appears.\nThe solution: Always define a fallback background-color along with background-image:\nbody { background-color: #0f172a; /* fallback si la imagen no carga */ background-image: url(\u0026#34;/patron.svg\u0026#34;); background-size: 120px 120px; background-repeat: repeat; } The color acts as a safety net. The user never sees a white flash while the SVG loads.\nbackground-attachment: fixed and the mobile problem # The problem: The parallax effect with background-attachment: fixed looks good on desktop but on mobile the background appears static, cropped, or shifted strangely.\nWhy it happens: Most mobile browsers ignore fixed on elements that aren\u0026rsquo;t html/body, and some implement it with known bugs in iOS Safari.\nThe solution: Disable it on mobile:\nbody { background-attachment: fixed; } @media (max-width: 768px) { body { background-attachment: scroll; /* o simplemente eliminar el patrón */ background-image: none; } } z-index and content disappearing behind the background # The problem: You add a decorative background and suddenly text or interactive elements disappear or become inaccessible.\nWhy it happens: The background has a higher z-index than the content, or the content doesn\u0026rsquo;t have position defined (necessary for z-index to work).\n/* El fondo queda detrás */ .fondo-decorativo { position: fixed; z-index: 0; } /* El contenido queda encima */ main, article, .contenido { position: relative; z-index: 1; } Specificity: when your CSS doesn\u0026rsquo;t override the framework\u0026rsquo;s # The problem: You write a CSS rule but it has no effect. The inspector shows it\u0026rsquo;s being overridden by the framework (Tailwind, Bootstrap, etc.).\nWhy it happens: CSS specificity determines which rule wins. A class selector (.dark body) has less weight than a compound selector from the framework.\nSolutions in order of preference:\n/* 1. Aumentar especificidad añadiendo el padre */ html.dark body { ... } /* 2. Usar :is() para agrupar sin perder especificidad */ :is(html.dark) body { ... } /* 3. !important — solo como último recurso */ body { background-color: #0f172a !important; } Avoid !important whenever you can — it creates a specificity debt that accumulates and makes CSS impossible to maintain.\nCSS Variables: order matters # The problem: You define CSS variables (custom properties) but in some contexts they don\u0026rsquo;t work.\nWhy it occurs: CSS variables (custom properties) are only accessible in the element where they are defined and its descendants. If you define them in body, they will not be available in html.\n/* BIEN: definir en :root para disponibilidad global */ :root { --color-fondo: #0f172a; --color-texto: #f1f5f9; } /* Luego úsalas en cualquier elemento */ body { background-color: var(--color-fondo); color: var(--color-texto); } Quick debugging with the inspector # When something doesn\u0026rsquo;t work:\nOpen DevTools (F12) and select the problematic element In the Styles tab, look for crossed-out properties — they are being overridden Filter by :hov to see styles for states (:hover, :focus) Toggle dark mode from DevTools: Rendering panel → Emulate CSS media feature prefers-color-scheme Use the Computed tab to see the final value that actually applies Most of these errors have the same root cause: assuming that the style propagates upward in the DOM when it actually only goes down. html and body are the starting point — make sure they look exactly like you want before worrying about the rest.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/css-errores-comunes-estilos/","section":"Posts","summary":"CSS seems simple until something doesn’t work as you expect. You spend hours staring at the browser inspector, change one property, then another, and the problem persists. This article collects the most frequent errors I’ve encountered building this blog, with direct solutions.\nThe background that disappears when scrolling # The problem: The page looks good in the visible area, but when you scroll down a white or unexpected background appears.\n","title":"CSS that doesn't fail: common styling mistakes on web pages and how to avoid them","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/dark-mode/","section":"Tags","summary":"","title":"Dark-Mode","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/categories/desarrollo-web/","section":"Categories","summary":"","title":"Desarrollo Web","type":"categories"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/categories/dise%C3%B1o/","section":"Categories","summary":"","title":"Diseño","type":"categories"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/dise%C3%B1o/","section":"Tags","summary":"","title":"Diseño","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/email/","section":"Tags","summary":"","title":"Email","type":"tags"},{"content":"Having a server at home has an obvious weak point: if the power goes out, the router fails, or the disk dies, your website disappears. The solution is to have a replica in the cloud ready to activate in minutes.\nThe architecture # [servidor-casa] → rsync cada 6h → [VPS réplica] servicios activos réplica en espera TTL DNS: 5 min Uptime Kuma vigilando The home server pushes content to the VPS every 6 hours. If the server goes down, I change the DNS and in 5 minutes the VPS serves the site.\nWhy push and not pull? # The home server is behind a residential router (Digi). The router only has ports 80 and 443 open. The VPS cannot connect via SSH to the home server directly.\nThe solution: the home server pushes to the VPS (it has free outgoing SSH), the VPS only receives.\nVPS preparation # The VPS (Debian 12, 2 CPUs, 4GB RAM, 30GB disk) already had Docker installed. First, security:\n# UFW: solo lo necesario sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable # Fail2ban SSH sudo apt-get install -y fail2ban # /etc/fail2ban/jail.local [DEFAULT] bantime = 7d findtime = 1h maxretry = 3 ignoreip = 127.0.0.1/8 \u0026lt;IP_PUBLICA_CASA\u0026gt; [sshd] enabled = true # SSH: solo clave pública sudo sed -i \u0026#39;s/#PasswordAuthentication yes/PasswordAuthentication no/\u0026#39; /etc/ssh/sshd_config sudo systemctl reload sshd Traefik as reverse proxy # Same Traefik v2.11 as on the home server, with automatic Let\u0026rsquo;s Encrypt:\nservices: traefik: image: traefik:v2.11 command: - --providers.docker=true - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.email=tu@email.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; Uptime Kuma: external monitoring # Uptime Kuma monitors the home server from outside. If it doesn\u0026rsquo;t respond, immediate email alert:\nuptime-kuma: image: louislam/uptime-kuma:1 volumes: - ./data:/app/data labels: - traefik.enable=true - traefik.http.routers.uptime.rule=Host(`uptime.serviciosrogeliowar.com`) - traefik.http.routers.uptime.entrypoints=websecure - traefik.http.routers.uptime.tls.certresolver=letsencrypt Standby replicas # The web and blog services are running on the VPS but with traefik.enable=false — Traefik ignores them, they\u0026rsquo;re not accessible from the internet. They only activate in an emergency:\nweb-replica: image: nginx:alpine labels: - traefik.enable=false # ← cambiar a true en emergencia Rsync script from the home server # #!/bin/bash # ~/infra/sync-to-vps.sh VPS=\u0026#34;usuario@\u0026lt;IP_PUBLICA_VPS\u0026gt;\u0026#34; SSH_KEY=\u0026#34;~/.ssh/id_ed25519\u0026#34; rsync -az --delete \\ -e \u0026#34;ssh -i $SSH_KEY -o StrictHostKeyChecking=no\u0026#34; \\ ~/infra/blog/public/ \\ ${VPS}:~/infra/blog/public/ rsync -az --delete \\ -e \u0026#34;ssh -i $SSH_KEY -o StrictHostKeyChecking=no\u0026#34; \\ ~/infra/web/html/ \\ ${VPS}:~/infra/web/html/ Crontab on the home server:\n0 */6 * * * ~/infra/sync-to-vps.sh Emergency failover procedure # When the home server goes down:\nEdit on the VPS: change traefik.enable=false to traefik.enable=true in web and blog docker compose up -d in each directory In the DNS panel, change the A record of your domain from the home server IP to the VPS IP With a 5-minute TTL, in less than 10 minutes the site is back When the home server recovers, reverse process: restore DNS, change back to traefik.enable=false on the VPS.\nResult # Uptime Kuma monitoring from outside at uptime.serviciosrogeliowar.com rsync automatic every 6 hours — maximum 6 hours of content lost in a failure Recovery time (RTO): ~5 minutes Maximum data loss (RPO): ~6 hours Additional cost: just the VPS (I already had it) For a home server, this architecture is more than sufficient.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/replica-emergencia-vps/","section":"Posts","summary":"Having a server at home has an obvious weak point: if the power goes out, the router fails, or the disk dies, your website disappears. The solution is to have a replica in the cloud ready to activate in minutes.\nThe architecture # [servidor-casa] → rsync cada 6h → [VPS réplica] servicios activos réplica en espera TTL DNS: 5 min Uptime Kuma vigilando The home server pushes content to the VPS every 6 hours. If the server goes down, I change the DNS and in 5 minutes the VPS serves the site.\n","title":"Emergency replica: how to have your home server backed up on a VPS","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/fail2ban/","section":"Tags","summary":"","title":"Fail2ban","type":"tags"},{"content":" The Problem: Brute Force Attacks # After exposing my Ubuntu server to the internet, I spent a night reviewing logs. SSH received failed login attempts every second. Nginx also had suspicious requests to common routes. I needed something to block these attempts automatically. Fail2ban was my solution.\nInstallation # sudo apt update sudo apt install fail2ban sudo systemctl start fail2ban sudo systemctl enable fail2ban Verify that it\u0026rsquo;s running:\nsudo systemctl status fail2ban Fail2ban Structure # Fail2ban works like this: it monitors logs, detects failure patterns, and creates firewall rules to block IPs. It has three key components:\nJails: define which service to protect Filters: regex patterns to detect failed attempts Actions: what to do when an attack is detected (ban, email, etc) SSH Configuration # The SSH jail comes preconfigured, but I customized it. Create file /etc/fail2ban/jail.local:\nsudo nano /etc/fail2ban/jail.local [DEFAULT] bantime = 3600 findtime = 600 maxretry = 5 [sshd] enabled = true port = ssh filter = sshd logpath = /var/log/auth.log maxretry = 3 Explanation:\nbantime: seconds the block lasts (1 hour) findtime: window in seconds to count attempts (10 min) maxretry: failed attempts before banning (3 in SSH, more restrictive) Nginx Configuration # For Nginx I needed to create a custom jail. First, the filter in /etc/fail2ban/filter.d/nginx-http-auth.conf:\nsudo nano /etc/fail2ban/filter.d/nginx-http-auth.conf [Definition] failregex = ^\u0026lt;HOST\u0026gt; - \\S+ \\[\\S+ \\S+\\] \u0026#34;.*?\u0026#34; (401|403) .*$ ignoreregex = Then, add the jail to the local file:\n[nginx-http-auth] enabled = true port = http,https filter = nginx-http-auth logpath = /var/log/nginx/error.log maxretry = 5 findtime = 600 bantime = 3600 Apply Changes # sudo systemctl restart fail2ban Verify that the jails are active:\nsudo fail2ban-client status See detailed SSH status:\nsudo fail2ban-client status sshd Monitoring # After a few hours, I checked the activity:\nsudo fail2ban-client status sshd The output shows banned IPs. To see jail details:\nsudo tail -f /var/log/fail2ban.log Unblock an IP (just in case) # If I made a mistake and blocked my own IP:\nsudo fail2ban-client set sshd unbanip \u0026lt;IP\u0026gt; Important Notes # Fail2ban doesn\u0026rsquo;t replace SSH keys. I continued using key authentication, not passwords. I increased maxretry in SSH to 3 because it\u0026rsquo;s more restrictive than 5 in web applications. Nginx logs must be in combined format. Check /etc/nginx/nginx.conf. For filter changes, restart: sudo systemctl restart fail2ban. Result # After implementing this, brute force attempts disappeared. The logs stopped being a mess. The server feels more at ease.\nFail2ban isn\u0026rsquo;t a silver bullet, but it\u0026rsquo;s an effective shield against basic automation. It\u0026rsquo;s worth the time spent configuring it correctly.\nRecommended Equipment # YubiKey 5 NFC — Physical security key for 2FA and secure SSH access Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/fail2ban-para-proteger-ssh-y-nginx-configuracion-practica-en-ubuntu/","section":"Posts","summary":"The Problem: Brute Force Attacks # After exposing my Ubuntu server to the internet, I spent a night reviewing logs. SSH received failed login attempts every second. Nginx also had suspicious requests to common routes. I needed something to block these attempts automatically. Fail2ban was my solution.\nInstallation # sudo apt update sudo apt install fail2ban sudo systemctl start fail2ban sudo systemctl enable fail2ban Verify that it’s running:\n","title":"Fail2ban to protect SSH and Nginx: practical configuration on Ubuntu","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/failover/","section":"Tags","summary":"","title":"Failover","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/frontend/","section":"Tags","summary":"","title":"Frontend","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/gitlab/","section":"Tags","summary":"","title":"Gitlab","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/gmail/","section":"Tags","summary":"","title":"Gmail","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/google-search-console/","section":"Tags","summary":"","title":"Google-Search-Console","type":"tags"},{"content":"When I put my first site in production on a home server, I made the mistake of thinking Google would discover it on its own. It didn\u0026rsquo;t. After a week with no trace in search results, I understood that I needed to be more proactive. Here\u0026rsquo;s what I learned setting up Google Search Console from scratch.\nWhy you need Google Search Console # Google Search Console is not optional. It\u0026rsquo;s your direct communication with Google about your site. It shows you indexing errors, security issues, and most importantly: it lets you tell Google exactly which pages to index and when.\nWithout it, you depend on Google\u0026rsquo;s bot discovering your site organically. With a new site on a home server, that can take weeks or months.\nStep 1: Verify your domain # Go to Google Search Console with your Google account. If you don\u0026rsquo;t have one, create one. It\u0026rsquo;s free.\nClick on \u0026ldquo;Add property\u0026rdquo; and select the property type. You have two options:\nDomain: verifies the entire domain (recommended) URL prefix: verifies only a specific URL I chose domain because I wanted to cover everything: example.com, www.example.com, and any future subdomain.\nDNS verification # Google will give you a TXT record to add to your domain provider. In my case I used Namecheap.\nThe record looks like this:\ngoogle-site-verification=ABC123XYZ... I accessed my registrar\u0026rsquo;s control panel, went to DNS settings, and added a new TXT record with that value. I waited a few minutes for it to propagate.\nGo back to Google Search Console and click \u0026ldquo;Verify\u0026rdquo;. If everything is correct, you\u0026rsquo;ll see the confirmation message.\nTip: DNS verification is definitive. Google will detect it automatically in future properties on the same domain.\nStep 2: Create and optimize your sitemap # A sitemap is an XML file that lists all your pages. Google uses it to discover content it might otherwise miss.\nIf you use a CMS like WordPress, Astro, or Next.js, you probably already have a plugin or generator. In my case, with a static site, I generated it manually.\nA basic sitemap looks like this:\n\u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;urlset xmlns=\u0026#34;http://www.sitemaps.org/schemas/sitemap/0.9\u0026#34;\u0026gt; \u0026lt;url\u0026gt; \u0026lt;loc\u0026gt;https://example.com/\u0026lt;/loc\u0026gt; \u0026lt;lastmod\u0026gt;2026-04-25\u0026lt;/lastmod\u0026gt; \u0026lt;changefreq\u0026gt;weekly\u0026lt;/changefreq\u0026gt; \u0026lt;priority\u0026gt;1.0\u0026lt;/priority\u0026gt; \u0026lt;/url\u0026gt; \u0026lt;url\u0026gt; \u0026lt;loc\u0026gt;https://example.com/articulos/\u0026lt;/loc\u0026gt; \u0026lt;lastmod\u0026gt;2026-04-20\u0026lt;/lastmod\u0026gt; \u0026lt;changefreq\u0026gt;daily\u0026lt;/changefreq\u0026gt; \u0026lt;priority\u0026gt;0.8\u0026lt;/priority\u0026gt; \u0026lt;/url\u0026gt; \u0026lt;/urlset\u0026gt; I saved the file as sitemap.xml in the root of the web server (in /var/www/html/ in my case).\nImportant note: include the \u0026lt;lastmod\u0026gt; tag with the actual date. Google uses this to know if your content changed.\nStep 3: Submit the sitemap to Google # Go back to Google Search Console, go to \u0026ldquo;Sitemaps\u0026rdquo; in the left menu, and paste the complete URL:\nhttps://example.com/sitemap.xml Click \u0026ldquo;Submit\u0026rdquo;. Google will process it within minutes.\nYou should see a \u0026ldquo;Success\u0026rdquo; status with the number of URLs found. If there are errors, Google will show them to you here.\nStep 4: Optimize your robots.txt file # While you\u0026rsquo;re here, make sure your robots.txt points to your sitemap:\nUser-agent: * Allow: / Disallow: /admin/ Disallow: /private/ Sitemap: https://example.com/sitemap.xml This tells Google where to find your sitemap without having to guess.\nResults # After submitting my sitemap, Google indexed 80% of my pages within 24 hours. Within a week they were all indexed. Searches started showing my articles.\nIt\u0026rsquo;s not magic, but it is effective. Google Search Console is a tool that every site owner must use, without exception.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/como-indexar-tu-sitio-web-en-google-search-console-guia-practica-con-sitemap-y-verificacion-de-dominio/","section":"Posts","summary":"When I put my first site in production on a home server, I made the mistake of thinking Google would discover it on its own. It didn’t. After a week with no trace in search results, I understood that I needed to be more proactive. Here’s what I learned setting up Google Search Console from scratch.\nWhy you need Google Search Console # Google Search Console is not optional. It’s your direct communication with Google about your site. It shows you indexing errors, security issues, and most importantly: it lets you tell Google exactly which pages to index and when.\n","title":"How to index your website in Google Search Console: practical guide with sitemap and domain verification","type":"posts"},{"content":" Introduction # A few months ago I decided to stop using expensive cloud services and set up my own infrastructure at home. The solution I found was combining Docker with Traefik. It works well and now I have several services running under HTTPS without manually touching a certificate. I\u0026rsquo;ll tell you how I did it.\nWhat you need # A server with Docker installed (any Linux machine with 2GB of RAM is enough). Your own domain. A bit of patience with DNS. That\u0026rsquo;s it.\nIf you don\u0026rsquo;t have a dedicated server, you have options depending on budget and power consumption: a Raspberry Pi 3 B+ (affiliate link) is perfect for lightweight services with minimal power consumption. If you need more power, a laptop like the Lenovo V15 (affiliate link) is a very versatile option: besides being a home server, it has the capacity to run industrial software from brands like Siemens (TIA Portal, SIMATIC) or other automation environments that demand real resources. One device, two uses.\nThe plan # I\u0026rsquo;m going to use Traefik as a reverse proxy. It automatically handles Let\u0026rsquo;s Encrypt certificates, routes traffic to the correct containers, and serves HTTPS without you having to do anything once it\u0026rsquo;s configured. It\u0026rsquo;s clean and it works.\nStep 1: Prepare Docker Compose # Create a folder for your stack:\nmkdir -p ~/docker/traefik cd ~/docker/traefik This will be your docker-compose.yml file:\nversion: \u0026#39;3.8\u0026#39; services: traefik: image: traefik:v2.10 container_name: traefik restart: always ports: - \u0026#34;80:80\u0026#34; - \u0026#34;443:443\u0026#34; volumes: - /var/run/docker.sock:/var/run/docker.sock - ./traefik.yml:/traefik.yml - ./acme.json:/acme.json networks: - web networks: web: driver: bridge Create the traefik.yml file:\napi: insecure: true dashboard: true entryPoints: web: address: \u0026#34;:80\u0026#34; http: redirections: entryPoint: to: websecure websecure: address: \u0026#34;:443\u0026#34; certificatesResolvers: letsencrypt: acme: email: tu-email@example.com storage: acme.json httpChallenge: entryPoint: web providers: docker: endpoint: unix:///var/run/docker.sock exposedByDefault: false file: filename: traefik.yml Create the acme.json file with restrictive permissions:\ntouch acme.json chmod 600 acme.json Step 2: Start Traefik # docker-compose up -d Verify it\u0026rsquo;s running:\ndocker-compose logs traefik Step 3: Configure your domain # At your DNS provider, point your domain (and a wildcard) to your server\u0026rsquo;s public IP:\nexample.com A TU_IP_PUBLICA *.example.com A TU_IP_PUBLICA Wait for it to propagate (typically 15 minutes).\nStep 4: Add your first service # I\u0026rsquo;m going to add a simple example. Modify the docker-compose.yml:\nservices: traefik: # ... config anterior whoami: image: traefik/whoami restart: always labels: - \u0026#34;traefik.enable=true\u0026#34; - \u0026#34;traefik.http.routers.whoami.rule=Host(`whoami.example.com`)\u0026#34; - \u0026#34;traefik.http.routers.whoami.entrypoints=websecure\u0026#34; - \u0026#34;traefik.http.routers.whoami.tls.certresolver=letsencrypt\u0026#34; - \u0026#34;traefik.http.services.whoami.loadbalancer.server.port=80\u0026#34; networks: - web Redeploy:\ndocker-compose up -d Wait 30 seconds and go to https://whoami.example.com. The certificate is generated automatically.\nStep 5: Add more services # For each new service, just add labels similar to the whoami ones. Traefik takes care of the rest. It\u0026rsquo;s that simple.\nPractical considerations # Backup acme.json: It\u0026rsquo;s your certificates file. Back it up regularly or you\u0026rsquo;ll lose the certificates.\nFirewall: Open ports 80 and 443 on your router pointing to the server.\nDynamic IP: If your ISP changes your IP (common in residential), use a DDNS service.\nDashboard: Traefik has a dashboard at http://localhost:8080 (only from the local machine for security).\nCommon issues # If certificates aren\u0026rsquo;t being generated, check the logs: docker-compose logs traefik. Usually it\u0026rsquo;s a DNS or firewall problem.\nIf a service doesn\u0026rsquo;t respond, verify that the port label matches the container\u0026rsquo;s internal port.\nConclusion # With this setup I\u0026rsquo;ve deployed blog, wiki, nextcloud and other services at home without spending on SSL or commercial reverse proxy. Traefik is a beast at this. It\u0026rsquo;s well worth spending an hour configuring it properly.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker and monitoring Lenovo V15 — Versatile laptop as a home server or for industrial software Foldable aluminum laptop stand with adjustable angle — Essential ergonomics if you use the laptop as a workstation Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/como-montar-tu-propia-infraestructura-web-en-casa-con-docker-y-traefik-desde-cero-hasta-https-automatico/","section":"Posts","summary":"Introduction # A few months ago I decided to stop using expensive cloud services and set up my own infrastructure at home. The solution I found was combining Docker with Traefik. It works well and now I have several services running under HTTPS without manually touching a certificate. I’ll tell you how I did it.\nWhat you need # A server with Docker installed (any Linux machine with 2GB of RAM is enough). Your own domain. A bit of patience with DNS. That’s it.\n","title":"How to set up your own web infrastructure at home with Docker and Traefik: from zero to automatic HTTPS","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/https/","section":"Tags","summary":"","title":"Https","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/ia/","section":"Tags","summary":"","title":"IA","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/indexaci%C3%B3n/","section":"Tags","summary":"","title":"Indexación","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/lets-encrypt/","section":"Tags","summary":"","title":"Let's-Encrypt","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/loki/","section":"Tags","summary":"","title":"Loki","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/categories/monitoring/","section":"Categories","summary":"","title":"Monitoring","type":"categories"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/monitoring/","section":"Tags","summary":"","title":"Monitoring","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/msmtp/","section":"Tags","summary":"","title":"Msmtp","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/node-exporter/","section":"Tags","summary":"","title":"Node-Exporter","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/productividad/","section":"Tags","summary":"","title":"Productividad","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/red/","section":"Tags","summary":"","title":"Red","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/categories/redes/","section":"Categories","summary":"","title":"Redes","type":"categories"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/reverse-proxy/","section":"Tags","summary":"","title":"Reverse-Proxy","type":"tags"},{"content":" Why you need this # When you run a server at home, you need to know if something strange happens. A script that sends you an email when it detects a failed login attempt, an expiring certificate, or a nearly full disk is invaluable. The problem is that your ISP blocks port 25, so you can\u0026rsquo;t use sendmail directly. That\u0026rsquo;s where msmtp comes in.\nmsmtp is a minimalist SMTP client. It\u0026rsquo;s not a full mail server, just sends emails through external servers like Gmail. Perfect for cases like ours.\nInstallation # On Debian/Ubuntu:\nsudo apt-get update sudo apt-get install msmtp msmtp-mta The msmtp-mta option is important because it creates a symbolic link that makes other programs think you\u0026rsquo;re using traditional sendmail.\nBasic configuration with Gmail # Gmail has two options: app password or using the SMTP protocol directly. I\u0026rsquo;ll use app password because it\u0026rsquo;s more secure and works without enabling \u0026ldquo;less secure apps\u0026rdquo;.\nFirst, create an app password on your Google account:\nGo to myaccount.google.com Security → App passwords (you need 2FA enabled) Select \u0026ldquo;Mail\u0026rdquo; and \u0026ldquo;Other (custom)\u0026rdquo; Gmail generates a 16-character password for you Now create or edit ~/.msmtprc:\nnano ~/.msmtprc Add this:\ndefaults auth on tls on tls_trust_file /etc/ssl/certs/ca-certificates.crt logfile ~/.msmtp.log account gmail host smtp.gmail.com port 587 from tu-email@gmail.com user tu-email@gmail.com password tu-contraseña-de-aplicacion account default : gmail Critical permissions:\nchmod 600 ~/.msmtprc This is important. If other users can read the file, they see your password.\nInitial test # Test that it works:\necho \u0026#34;Cuerpo del email\u0026#34; | msmtp tu-email@gmail.com -S from=tu-email@gmail.com Check your inbox. If you receive the email, it\u0026rsquo;s working.\nUsing msmtp from security scripts # Now integrate this into your alerts. Here\u0026rsquo;s a simple example that monitors failed SSH attempts:\n#!/bin/bash FAILED_ATTEMPTS=$(grep \u0026#34;Failed password\u0026#34; /var/log/auth.log | wc -l) THRESHOLD=10 if [ $FAILED_ATTEMPTS -gt $THRESHOLD ]; then { echo \u0026#34;Asunto: ALERTA - Múltiples intentos de acceso SSH fallidos\u0026#34; echo \u0026#34;\u0026#34; echo \u0026#34;Se detectaron $FAILED_ATTEMPTS intentos fallidos en las últimas 24 horas\u0026#34; echo \u0026#34;\u0026#34; echo \u0026#34;Últimos intentos:\u0026#34; grep \u0026#34;Failed password\u0026#34; /var/log/auth.log | tail -5 } | msmtp tu-email@gmail.com fi Save it in /usr/local/bin/check-ssh-alerts.sh and make it executable:\nsudo chmod +x /usr/local/bin/check-ssh-alerts.sh Automate with cron # Add to crontab to run every hour:\nsudo crontab -e 0 * * * * /usr/local/bin/check-ssh-alerts.sh Common problems # \u0026ldquo;SMTP Error: 535\u0026rdquo; → Wrong password. Verify that you used the app password, not your regular Google password.\n\u0026ldquo;TLS connection refused\u0026rdquo; → Check that the certificate is in the correct path. Use ls /etc/ssl/certs/ca-certificates.crt.\nEmails not arriving → Check the log: cat ~/.msmtp.log. Gmail sometimes rejects if it detects suspicious activity.\nAdditional security # If the server runs as a regular user but scripts need to run as root, consider:\nsudo visudo And add:\nnobody ALL=(ALL) NOPASSWD: /usr/local/bin/check-ssh-alerts.sh This way you run the script without asking for a password in cron.\nConclusion # With msmtp you have automatic security alerts in minutes, without the hassle of setting up a full SMTP server. I use it on my home server to monitor iptables changes, expiring certificates, and load spikes. Sleeping soundly knowing that something will alert me if there\u0026rsquo;s a problem.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight low-power server to start your homelab Intel N100 Mini PC — Silent and efficient mini PC for 24/7 home server Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/notificaciones-de-seguridad-por-email-desde-el-terminal-con-msmtp-y-gmail/","section":"Posts","summary":"Why you need this # When you run a server at home, you need to know if something strange happens. A script that sends you an email when it detects a failed login attempt, an expiring certificate, or a nearly full disk is invaluable. The problem is that your ISP blocks port 25, so you can’t use sendmail directly. That’s where msmtp comes in.\n","title":"Security email notifications from the terminal with msmtp and Gmail","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/seo/","section":"Tags","summary":"","title":"Seo","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/sitemap/","section":"Tags","summary":"","title":"Sitemap","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/sitios-est%C3%A1ticos/","section":"Tags","summary":"","title":"Sitios Estáticos","type":"tags"},{"content":" Why Switch to Key-Based Authentication # After months of maintaining a home server with open SSH access, I got tired of brute force password attacks. Switching to public key authentication was the best security decision I made. Keys are mathematically impossible to crack through brute force, while passwords are always a target.\nSSH Key Generation # First, generate a key pair on your local machine (not on the server):\nssh-keygen -t ed25519 -C \u0026#34;tu_email@ejemplo.com\u0026#34; It will ask you where to save the key. Press Enter to use the default location (~/.ssh/id_ed25519). Then it will ask for a passphrase. I use a strong password here, because it protects your private key locally.\nAfter this you\u0026rsquo;ll have two files:\n~/.ssh/id_ed25519 - Your private key (never share this) ~/.ssh/id_ed25519.pub - Your public key (this goes on the server) Copy the Key to the Server # The safest method is using ssh-copy-id. From your local machine:\nssh-copy-id -i ~/.ssh/id_ed25519.pub usuario@servidor This will add your public key to the ~/.ssh/authorized_keys file on the server. You\u0026rsquo;ll still need your password for this step.\nIf ssh-copy-id doesn\u0026rsquo;t work, you can do it manually:\ncat ~/.ssh/id_ed25519.pub | ssh usuario@servidor \u0026#34;mkdir -p ~/.ssh \u0026amp;\u0026amp; cat \u0026gt;\u0026gt; ~/.ssh/authorized_keys \u0026amp;\u0026amp; chmod 600 ~/.ssh/authorized_keys\u0026#34; Verify It Works # Before disabling passwords, test that key-based access works:\nssh usuario@servidor If everything is good, you should log in without being asked for a password (or just the passphrase of your local key, if you set one).\nSSH Server Configuration # Now we edit /etc/ssh/sshd_config on the server:\nsudo nano /etc/ssh/sshd_config Find these lines and adjust them (remove the # if it\u0026rsquo;s commented out):\nPubkeyAuthentication yes PasswordAuthentication no PermitEmptyPasswords no PermitRootLogin no These are the critical lines:\nPubkeyAuthentication: Enables key-based authentication (should be yes) PasswordAuthentication: Change this to no to disable passwords PermitEmptyPasswords: Ensures there\u0026rsquo;s no access with empty password PermitRootLogin: It\u0026rsquo;s good practice to set this to no Apply the Changes # Before restarting the SSH service, verify that the configuration is valid:\nsudo sshd -t If it doesn\u0026rsquo;t return errors, restart the service:\nsudo systemctl restart ssh Final Test # Here comes the moment of truth. Open a new SSH session without closing the current one:\nssh usuario@servidor If you log in without problems, everything works. If not, keep the previous session open to revert changes.\nBackup and Checklist # Before doing this, I backup sshd_config:\nsudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup My checklist before disabling passwords:\nSSH key generated locally Public key copied to the server Key-based access tested correctly sshd -t without errors Backup of sshd_config done Test session open before restarting Result # Since I implemented this, the server logs are quiet. Zero successful brute force attempts. SSH keys are one of those security improvements that seems complicated at first but is completely worth it.\nRecommended Equipment # YubiKey 5 NFC — Physical security key for 2FA and secure SSH access Raspberry Pi 3 B+ — Lightweight, low-power server for starting your homelab Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/autenticacion-ssh-por-clave-publica-desactivar-contrasenas-en-ubuntu-server/","section":"Posts","summary":"Why Switch to Key-Based Authentication # After months of maintaining a home server with open SSH access, I got tired of brute force password attacks. Switching to public key authentication was the best security decision I made. Keys are mathematically impossible to crack through brute force, while passwords are always a target.\nSSH Key Generation # First, generate a key pair on your local machine (not on the server):\n","title":"SSH authentication by public key: disable passwords on Ubuntu Server","type":"posts"},{"content":"I\u0026rsquo;ve been wanting to document my infrastructure projects better for a while. After trying several options, I decided to set up a static blog with Hugo. The combination of Hugo + Blowfish turned out to be exactly what I needed: fast, clean, and easy to maintain.\nWhy Hugo and Blowfish # Hugo is a static site generator written in Go. It\u0026rsquo;s incredibly fast and requires no database or complicated dependencies. Blowfish is a modern, minimalist theme that\u0026rsquo;s well documented. Both work great on a home server with limited resources.\nInstallation on the server # The first step was installing Hugo. In my case I use Debian on the server:\nsudo apt-get update sudo apt-get install hugo Verify the installation:\nhugo version Create the site # I initialized the project in a folder within /home:\nhugo new site mi-blog cd mi-blog Add the Blowfish theme # I cloned the theme repository in the themes folder:\ngit clone https://github.com/nunocoracao/blowfish.git themes/blowfish Then I updated the configuration file hugo.toml:\nbaseURL = \u0026#34;https://mi-dominio.local/\u0026#34; languageCode = \u0026#34;es\u0026#34; title = \u0026#34;Servicios Rogeliowar\u0026#34; theme = \u0026#34;blowfish\u0026#34; [params] description = \u0026#34;Documentación técnica de infraestructura y servidores\u0026#34; author = \u0026#34;Rogelio\u0026#34; [menu] [[menu.main]] name = \u0026#34;Posts\u0026#34; pageRef = \u0026#34;/posts\u0026#34; weight = 10 [[menu.main]] name = \u0026#34;Sobre mí\u0026#34; pageRef = \u0026#34;/about\u0026#34; weight = 20 Create content # Articles go in the content/posts/ folder. Each one is a Markdown file:\nhugo new posts/mi-primer-articulo.md The generated file includes YAML frontmatter ready to edit:\n--- title: \u0026#34;Mi Primer Artículo\u0026#34; date: 2026-04-28 draft: false --- Contenido del artículo aquí... Local server for testing # Before publishing, I tested everything locally:\nhugo server -D The site will be available at http://localhost:1313/. The -D parameter includes drafts.\nGenerate static files # Once ready, I generated the final HTML files:\nhugo This creates the public/ folder with all compiled content.\nServe with Nginx # I copied the generated files to the Nginx folder:\nsudo cp -r public/* /var/www/mi-blog/ I configured a server block in Nginx:\nserver { listen 80; server_name mi-dominio.local; root /var/www/mi-blog; index index.html; location / { try_files $uri $uri/ =404; } } I reloaded Nginx:\nsudo systemctl reload nginx Automate builds # To avoid manually compiling every time I write an article, I created a simple script:\n#!/bin/bash cd /home/usuario/mi-blog hugo sudo cp -r public/* /var/www/mi-blog/ echo \u0026#34;Blog actualizado\u0026#34; I\u0026rsquo;ll save it as actualizar-blog.sh and give it execute permissions:\nchmod +x actualizar-blog.sh Final thoughts # After a week using this setup, I can say it\u0026rsquo;s solid. Hugo compiles everything in less than a second, Blowfish looks professional without needing extreme customization, and the home server handles everything without issues.\nThe best part: there\u0026rsquo;s no database to back up, no plugins to break, no security updates every week. Just static files served by Nginx. Exactly what I was looking for.\n--- ## Recommended equipment - **[Raspberry Pi 3 B+](https://amzn.to/4upmmwn)** — Lightweight low-power server to start your homelab - **[Raspberry Pi 4 (4GB)](https://amzn.to/4utrPSX)** — The perfect foundation for homelab, Docker and monitoring *Affiliate links. No extra cost to you.* ","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/blog-estatico-con-hugo-y-tema-blowfish-en-un-servidor-domestico/","section":"Posts","summary":"I’ve been wanting to document my infrastructure projects better for a while. After trying several options, I decided to set up a static blog with Hugo. The combination of Hugo + Blowfish turned out to be exactly what I needed: fast, clean, and easy to maintain.\nWhy Hugo and Blowfish # Hugo is a static site generator written in Go. It’s incredibly fast and requires no database or complicated dependencies. Blowfish is a modern, minimalist theme that’s well documented. Both work great on a home server with limited resources.\n","title":"Static blog with Hugo and Blowfish theme on a home server","type":"posts"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/terminal/","section":"Tags","summary":"","title":"Terminal","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/traefik/","section":"Tags","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/ubuntu/","section":"Tags","summary":"","title":"Ubuntu","type":"tags"},{"content":"","date":"30 April 2026","externalUrl":null,"permalink":"/en/tags/web/","section":"Tags","summary":"","title":"Web","type":"tags"},{"content":"One of the classic problems with having a server at home is secure remote access. Opening SSH ports directly to the world is a bad idea — you see it in the auth logs: hundreds of attempts per day. The elegant solution is a VPN, and WireGuard is today\u0026rsquo;s best available option.\nWhy WireGuard? # Compared to OpenVPN or IPSec:\nMuch faster — it\u0026rsquo;s integrated into the Linux kernel since version 5.6 Minimal configuration — the server config fits in 10 lines Modern cryptography — ChaCha20, Curve25519, BLAKE2 (see WireGuard protocol) Single UDP port — easy to open on the router Architecture # [Móvil/Portátil] ←── WireGuard túnel UDP 51820 ──→ [Router casa] → [Servidor doméstico 192.168.1.X] 10.10.0.2 10.10.0.1 The server acts as a VPN hub. When I connect, I get the IP 10.10.0.2 and can access any service on the local network as if I were at home.\nInstallation on Ubuntu # sudo apt-get install -y wireguard WireGuard comes in Ubuntu repos since 20.04. In newer versions the kernel module is included by default.\nKey generation # WireGuard uses public key cryptography. We generate a pair for the server and another for each client:\n# Claves del servidor wg genkey | tee server_private.key | wg pubkey \u0026gt; server_public.key # Claves del cliente (móvil o portátil) wg genkey | tee client_private.key | wg pubkey \u0026gt; client_public.key Important: private keys never leave the device that generates them. Only public keys are exchanged.\nServer configuration # File /etc/wireguard/wg0.conf:\n[Interface] Address = 10.10.0.1/24 ListenPort = 51820 PrivateKey = \u0026lt;CLAVE_PRIVADA_SERVIDOR\u0026gt; PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eno1 -j MASQUERADE PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eno1 -j MASQUERADE [Peer] # Móvil / portátil PublicKey = \u0026lt;CLAVE_PUBLICA_CLIENTE\u0026gt; AllowedIPs = 10.10.0.2/32 The iptables rules in PostUp/PostDown enable packet forwarding (NAT) so the client can reach the local network, not just the server.\nStrict permissions on the file:\nsudo chmod 600 /etc/wireguard/wg0.conf Enable IP forwarding # Without this, the server doesn\u0026rsquo;t forward packets between interfaces:\n# Temporal (hasta reinicio) sudo sysctl -w net.ipv4.ip_forward=1 # Permanente sudo sed -i \u0026#39;s/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/\u0026#39; /etc/sysctl.conf Start WireGuard # sudo systemctl enable --now wg-quick@wg0 Verify it\u0026rsquo;s active:\nsudo wg show wg0 It should show the interface listening on port 51820 and the registered peer.\nFirewall (UFW) # sudo ufw allow 51820/udp sudo ufw reload Client configuration # File wg-casa.conf for the laptop or mobile:\n[Interface] Address = 10.10.0.2/24 PrivateKey = \u0026lt;CLAVE_PRIVADA_CLIENTE\u0026gt; DNS = 1.1.1.1 [Peer] PublicKey = \u0026lt;CLAVE_PUBLICA_SERVIDOR\u0026gt; Endpoint = \u0026lt;IP_PUBLICA_CASA\u0026gt;:51820 AllowedIPs = 10.10.0.0/24 PersistentKeepalive = 25 AllowedIPs = 10.10.0.0/24 means only traffic to the VPN network goes through the tunnel — the rest of the internet still goes out directly. If you wanted to route all traffic (including web browsing) through home, you\u0026rsquo;d use 0.0.0.0/0.\nPersistentKeepalive = 25 keeps the connection alive even with no traffic — useful on mobile networks that close inactive UDP connections.\nRouter: port forwarding # On the router you need to redirect port 51820 UDP to the server\u0026rsquo;s local IP (e.g. 192.168.1.X). It\u0026rsquo;s usually in Settings → NAT → Port Forwarding.\nQR for mobile # Instead of typing the config on the mobile, we generate a QR:\nsudo apt-get install -y qrencode qrencode -t ansiutf8 \u0026lt; cliente.conf The WireGuard app (iOS/Android) scans it directly.\nVerification # To test that it actually works, you need to connect from a different network than home — for example, mobile data:\nDisable wifi on the mobile Activate the WireGuard tunnel in the app Access a server service by local IP (e.g. http://192.168.1.X) If it responds, the tunnel is working correctly.\nAdditional security # Private keys never travel over the network — only public keys are exchanged No users or passwords — purely cryptographic authentication One peer = one public key — if you lose a device, delete its [Peer] from the server and it no longer has access Fail2ban doesn\u0026rsquo;t apply — WireGuard silently discards invalid packets without responding Adding more clients # For each new device, we generate a new key pair and add an additional [Peer] block on the server with its public key and a different IP (10.10.0.3, 10.10.0.4\u0026hellip;):\nsudo wg set wg0 peer \u0026lt;NUEVA_CLAVE_PUBLICA\u0026gt; allowed-ips 10.10.0.3/32 sudo wg-quick save wg0 There\u0026rsquo;s no need to restart the service — WireGuard adds peers on the fly.\nFull tunnel mode: all traffic through the VPN # By default, the client only routes local network traffic (10.10.0.0/24) through the tunnel. If you want all device browsing to go through your server — useful on public networks, hotels, or unknown WiFi — change AllowedIPs on the client:\n# Solo red local (por defecto) AllowedIPs = 10.10.0.0/24 # Todo el tráfico (modo privacidad total) AllowedIPs = 0.0.0.0/0, ::/0 With 0.0.0.0/0 all browsing goes out through your home IP. Advantages: privacy on public networks, your real IP at all times. Downside: your home upload speed limits the remote device\u0026rsquo;s browsing.\nIn the WireGuard app (mobile or desktop) you can change this by editing the tunnel without needing to touch the server.\nWith this I have full access to my home network from anywhere, without exposing any additional ports to the world and with modern cryptography. The natural next step is to set up automatic backups from the VPS to the home server, using this tunnel as a secure channel.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"30 April 2026","externalUrl":null,"permalink":"/en/posts/wireguard-vpn-servidor-dom%C3%A9stico/","section":"Posts","summary":"One of the classic problems with having a server at home is secure remote access. Opening SSH ports directly to the world is a bad idea — you see it in the auth logs: hundreds of attempts per day. The elegant solution is a VPN, and WireGuard is today’s best available option.\nWhy WireGuard? # Compared to OpenVPN or IPSec:\n","title":"WireGuard VPN: Access your home server from anywhere","type":"posts"},{"content":"This blog was born from a real process.\nI had a domain, a home server, and the desire to build something of my own. Instead of following a generic tutorial, I decided to document exactly what I was doing — errors included.\nWhat you\u0026rsquo;ll find here # Articles about what I\u0026rsquo;m building and learning:\nLinux server configuration Service deployment with Docker Networks, DNS, and security Automation and infrastructure No shortcuts, no simplifications. Real process, documented step by step.\nRecommended equipment # Raspberry Pi 3 B+ — Lightweight, low-power server to start your homelab Raspberry Pi 4 (4GB) — The perfect foundation for homelab, Docker, and monitoring Affiliate links. No extra cost to you.\n","date":"29 April 2026","externalUrl":null,"permalink":"/en/posts/bienvenida/","section":"Posts","summary":"This blog was born from a real process.\nI had a domain, a home server, and the desire to build something of my own. Instead of following a generic tutorial, I decided to document exactly what I was doing — errors included.\nWhat you’ll find here # Articles about what I’m building and learning:\nLinux server configuration Service deployment with Docker Networks, DNS, and security Automation and infrastructure No shortcuts, no simplifications. Real process, documented step by step.\n","title":"How This Blog Was Born","type":"posts"},{"content":"","date":"29 April 2026","externalUrl":null,"permalink":"/en/tags/inicio/","section":"Tags","summary":"","title":"Inicio","type":"tags"},{"content":" Hi, I\u0026rsquo;m Rogelio # I\u0026rsquo;m a sysadmin passionate about real infrastructure: the kind that runs on actual hardware, not just in the cloud. I\u0026rsquo;ve spent years building and maintaining my own web infrastructure from home, learning along the way everything that courses don\u0026rsquo;t teach.\nWhat you\u0026rsquo;ll find here # This blog is my public technical notebook. I document what I do, what I break, and how I fix it. No generic examples — everything comes from real cases.\nMain topics:\nDocker Infrastructure — compose, networking, volumes, reverse proxy with Traefik Monitoring — Prometheus, Grafana, Loki, real alerts Networks — WireGuard, fail2ban, SSH hardening, segmentation CI/CD — GitLab Runner, Hugo, automated deployments Automation — Python scripts, crons, AI agents My current stack # Main server: home machine running Linux Replica: VPS on Clouding with automatic DNS failover Services: Traefik, Grafana, Loki, Prometheus, Listmonk, Hugo Private tunnel: WireGuard between both servers Contact # You can find me at:\nLinkedIn: linkedin.com/in/rogeliowar GitLab: gitlab.com/rogeliowar Instagram: @rogeliowarr Email: serviciosrogeliowar@gmail.com ","date":"1 January 2026","externalUrl":null,"permalink":"/en/sobre-mi/","section":"Servicios Rogeliowar","summary":"Hi, I’m Rogelio # I’m a sysadmin passionate about real infrastructure: the kind that runs on actual hardware, not just in the cloud. I’ve spent years building and maintaining my own web infrastructure from home, learning along the way everything that courses don’t teach.\nWhat you’ll find here # This blog is my public technical notebook. I document what I do, what I break, and how I fix it. No generic examples — everything comes from real cases.\n","title":"About Me","type":"page"},{"content":"","externalUrl":null,"permalink":"/viajes/","section":"Viajes \u0026 Experiencias","summary":"","title":"Viajes \u0026 Experiencias","type":"viajes"}]