[{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/automatizaci%C3%B3n/","section":"Tags","summary":"","title":"Automatización","type":"tags"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/backups/","section":"Tags","summary":"","title":"Backups","type":"tags"},{"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":"29 de junio de 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":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/restic/","section":"Tags","summary":"","title":"Restic","type":"tags"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/seguridad/","section":"Tags","summary":"","title":"Seguridad","type":"tags"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/","section":"Servicios Rogeliowar","summary":"","title":"Servicios Rogeliowar","type":"page"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"29 de junio de 2026","externalUrl":null,"permalink":"/tags/wireguard/","section":"Tags","summary":"","title":"Wireguard","type":"tags"},{"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":"26 de junio de 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":"26 de junio de 2026","externalUrl":null,"permalink":"/tags/minio/","section":"Tags","summary":"","title":"Minio","type":"tags"},{"content":"","date":"26 de junio de 2026","externalUrl":null,"permalink":"/tags/postgresql/","section":"Tags","summary":"","title":"Postgresql","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":"24 de junio de 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":"","date":"24 de junio de 2026","externalUrl":null,"permalink":"/tags/cron/","section":"Tags","summary":"","title":"Cron","type":"tags"},{"content":"","date":"24 de junio de 2026","externalUrl":null,"permalink":"/tags/rsync/","section":"Tags","summary":"","title":"Rsync","type":"tags"},{"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":"22 de junio de 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":"22 de junio de 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":"19 de junio de 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":"19 de junio de 2026","externalUrl":null,"permalink":"/tags/nas/","section":"Tags","summary":"","title":"Nas","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/802.1x/","section":"Tags","summary":"","title":"802.1x","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/active-directory/","section":"Tags","summary":"","title":"Active-Directory","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/adcs/","section":"Tags","summary":"","title":"Adcs","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/administraci%C3%B3n-de-sistemas/","section":"Categories","summary":"","title":"Administración De Sistemas","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/an%C3%A1lisis-de-incidentes/","section":"Categories","summary":"","title":"Análisis De Incidentes","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"18 de junio de 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":"18 de junio de 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":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/cve/","section":"Tags","summary":"","title":"Cve","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/diagn%C3%B3stico/","section":"Categories","summary":"","title":"Diagnóstico","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/diagn%C3%B3stico/","section":"Tags","summary":"","title":"Diagnóstico","type":"tags"},{"content":"","date":"18 de junio de 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":"18 de junio de 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":"18 de junio de 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":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/fail2ban/","section":"Tags","summary":"","title":"Fail2ban","type":"tags"},{"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":"18 de junio de 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":"18 de junio de 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":"18 de junio de 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":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/fortigate/","section":"Tags","summary":"","title":"Fortigate","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/fortinet/","section":"Tags","summary":"","title":"Fortinet","type":"tags"},{"content":"","date":"18 de junio de 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":"18 de junio de 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":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/hardening/","section":"Tags","summary":"","title":"Hardening","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/mfa/","section":"Tags","summary":"","title":"Mfa","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/operaciones/","section":"Categories","summary":"","title":"Operaciones","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/operaciones/","section":"Tags","summary":"","title":"Operaciones","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/pam/","section":"Tags","summary":"","title":"Pam","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/peap/","section":"Tags","summary":"","title":"Peap","type":"tags"},{"content":"","date":"18 de junio de 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":"18 de junio de 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":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/python/","section":"Categories","summary":"","title":"Python","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/qr-code/","section":"Tags","summary":"","title":"Qr-Code","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/radius/","section":"Tags","summary":"","title":"Radius","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/redes/","section":"Categories","summary":"","title":"Redes","type":"categories"},{"content":"","date":"18 de junio de 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":"18 de junio de 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":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/secrets/","section":"Tags","summary":"","title":"Secrets","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/seguridad/","section":"Categories","summary":"","title":"Seguridad","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/smtp/","section":"Tags","summary":"","title":"Smtp","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/ssh/","section":"Tags","summary":"","title":"Ssh","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/ssl/","section":"Tags","summary":"","title":"Ssl","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/totp/","section":"Tags","summary":"","title":"Totp","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/ufw/","section":"Tags","summary":"","title":"Ufw","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/vpn/","section":"Tags","summary":"","title":"Vpn","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/wifi/","section":"Tags","summary":"","title":"Wifi","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/wifi/","section":"Categories","summary":"","title":"WiFi","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/winbind/","section":"Tags","summary":"","title":"Winbind","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/categories/windows/","section":"Categories","summary":"","title":"Windows","type":"categories"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/windows-server/","section":"Tags","summary":"","title":"Windows-Server","type":"tags"},{"content":"","date":"18 de junio de 2026","externalUrl":null,"permalink":"/tags/wpa-enterprise/","section":"Tags","summary":"","title":"Wpa-Enterprise","type":"tags"},{"content":"","date":"17 de junio de 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":"17 de junio de 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":"17 de junio de 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":"15 de junio de 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":"15 de junio de 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":"12 de junio de 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":"","date":"12 de junio de 2026","externalUrl":null,"permalink":"/tags/backup/","section":"Tags","summary":"","title":"Backup","type":"tags"},{"content":"","date":"12 de junio de 2026","externalUrl":null,"permalink":"/tags/systemd/","section":"Tags","summary":"","title":"Systemd","type":"tags"},{"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":"10 de junio de 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":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/grafana/","section":"Tags","summary":"","title":"Grafana","type":"tags"},{"content":"","date":"10 de junio de 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":"8 de junio de 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":"5 de junio de 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":"3 de junio de 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":"1 de junio de 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":"29 de mayo de 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":"29 de mayo de 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":"27 de mayo de 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":"25 de mayo de 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":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/categories/docker/","section":"Categories","summary":"","title":"Docker","type":"categories"},{"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":"25 de mayo de 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":"25 de mayo de 2026","externalUrl":null,"permalink":"/tags/docker-compose/","section":"Tags","summary":"","title":"Docker-Compose","type":"tags"},{"content":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/categories/infraestructura/","section":"Categories","summary":"","title":"Infraestructura","type":"categories"},{"content":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/tags/infraestructura/","section":"Tags","summary":"","title":"Infraestructura","type":"tags"},{"content":"","date":"24 de mayo de 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":"24 de mayo de 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":"24 de mayo de 2026","externalUrl":null,"permalink":"/categories/automatizaci%C3%B3n/","section":"Categories","summary":"","title":"Automatización","type":"categories"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/endpoints/","section":"Tags","summary":"","title":"Endpoints","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/inventario/","section":"Tags","summary":"","title":"Inventario","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/powershell/","section":"Tags","summary":"","title":"Powershell","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/rmm/","section":"Tags","summary":"","title":"Rmm","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/sysadmin/","section":"Tags","summary":"","title":"Sysadmin","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/windows/","section":"Tags","summary":"","title":"Windows","type":"tags"},{"content":"","date":"22 de mayo de 2026","externalUrl":null,"permalink":"/tags/cumplimiento/","section":"Tags","summary":"","title":"Cumplimiento","type":"tags"},{"content":"","date":"22 de mayo de 2026","externalUrl":null,"permalink":"/tags/monitorizaci%C3%B3n/","section":"Tags","summary":"","title":"Monitorización","type":"tags"},{"content":"","date":"22 de mayo de 2026","externalUrl":null,"permalink":"/tags/siem/","section":"Tags","summary":"","title":"Siem","type":"tags"},{"content":"","date":"22 de mayo de 2026","externalUrl":null,"permalink":"/tags/wazuh/","section":"Tags","summary":"","title":"Wazuh","type":"tags"},{"content":" Qué es el ENS y por qué importa # El Esquema Nacional de Seguridad (ENS) es el marco normativo de ciberseguridad obligatorio para las Administraciones Públicas españolas y para las empresas privadas que prestan servicios a estas. Está regulado por el Real Decreto 311/2022 y establece los principios, requisitos y medidas de seguridad que deben aplicar los sistemas de información que manejan datos o servicios públicos.\nEn la práctica, el ENS clasifica los sistemas según el impacto que tendría un incidente de seguridad sobre la organización y los ciudadanos. De esa clasificación depende el nivel de medidas que hay que implementar.\nLas tres categorías del ENS # El ENS define tres niveles de categorización:\nCategoría Básica Para sistemas cuyo compromiso tendría un impacto limitado. Suele aplicar a webs informativas, servicios internos de bajo riesgo o sistemas con datos no sensibles. Las medidas requeridas son las mínimas del marco.\nCategoría Media Para sistemas cuyo compromiso causaría un perjuicio considerable a la organización o a terceros. Es la categoría más habitual en entornos de gestión interna: ERP, portales de empleados, sistemas de RRHH, plataformas de gestión documental. Requiere controles activos de monitorización, gestión de incidentes y control de acceso.\nCategoría Alta Para sistemas críticos cuyo compromiso podría causar un daño grave o muy grave. Aplica a sistemas que gestionan infraestructuras críticas, datos de salud, sistemas judiciales o de defensa. Exige las medidas más estrictas del esquema.\nLa categoría media en la práctica # Un sistema clasificado como categoría media necesita, entre otros controles:\nMonitorización continua de eventos de seguridad Detección y gestión activa de incidentes Control de acceso privilegiado y trazabilidad de acciones Integridad de los ficheros del sistema Registro y análisis de logs centralizado Es exactamente aquí donde entra Wazuh.\nWazuh como plataforma de cumplimiento # Wazuh es una plataforma SIEM (Security Information and Event Management) open source que cubre de forma nativa la mayoría de controles de monitorización que exige la categoría media: análisis de logs en tiempo real, detección de intrusiones, monitorización de integridad de ficheros, inventario de software y respuesta activa ante incidentes.\nEn este artículo despliego un nodo único con Docker, genero los certificados TLS necesarios y añado reglas personalizadas orientadas a los controles de seguridad más habituales en entornos de categoría media.\nArquitectura del stack # El stack de Wazuh single-node tiene tres componentes:\nwazuh.manager — motor de análisis y correlación de eventos wazuh.indexer — almacenamiento basado en OpenSearch wazuh.dashboard — interfaz web (OpenSearch Dashboards) La comunicación entre componentes usa TLS mutuo con certificados propios, que generamos antes del primer arranque.\nEstructura del proyecto # 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 Variables de entorno # Crea el fichero .env a partir del ejemplo:\ncp .env.example .env Contenido mínimo:\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 Usa contraseñas de al menos 12 caracteres con mayúsculas, números y símbolos. El indexer las valida en el arranque.\nGenerar certificados TLS # Wazuh requiere certificados TLS para la comunicación interna entre manager, indexer y dashboard. El stack incluye un contenedor generador:\ndocker compose -f generate-certs.yml run --rm generator Esto crea config/wazuh_indexer_ssl_certs/ con:\nroot-ca.pem — CA raíz autofirmada 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: Script de despliegue # #!/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; Configuración del manager # El fichero wazuh_manager.conf define el comportamiento global. Puntos clave:\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; Reglas personalizadas de seguridad # Wazuh incluye miles de reglas predefinidas. Las propias van en local_rules.xml con IDs desde 100000. Este bloque implementa los controles de monitorización más habituales en entornos con requisitos de cumplimiento de categoría media: detección de ataques, control de acceso privilegiado, integridad de ficheros y disponibilidad de servicios.\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; Qué cubre cada bloque # Reglas Área de control Descripción 100001–100002 Detección de ataques Fuerza bruta y bloqueo de cuentas 100003, 100010 Acceso privilegiado Sudo y creación de usuarios 100020 Integridad del sistema Cambios en ficheros críticos 100030 Disponibilidad Caída inesperada de servicios Los niveles (8-10) determinan qué alertas generan notificación por email según el umbral configurado en email_alert_level.\nEnrollar un agente Linux # Desde el servidor donde instalar el agente:\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 Acceso al dashboard # Una vez el stack esté UP (el indexer tarda 2-3 minutos en inicializar):\nhttps://TU-IP:8443 Usuario: admin Contraseña: (la definida en INDEXER_ADMIN_PASSWORD) Importante: cambia todas las contraseñas por defecto antes de exponer el servicio en red.\nConclusión # Con este stack tienes un SIEM completo en un solo servidor capaz de cubrir los controles de monitorización que exigen los marcos de cumplimiento más habituales. El siguiente paso natural es añadir más agentes, configurar alertas por email o webhook para los eventos de nivel alto, y revisar los dashboards de cumplimiento que Wazuh incluye de serie para PCI-DSS, GDPR, HIPAA y el propio ENS. Para verificar el nivel de adecuación de un sistema al ENS, el CCN-CERT publica las guías de seguridad (series CCN-STIC) de referencia.\n","date":"22 de mayo de 2026","externalUrl":null,"permalink":"/posts/wazuh-siem-docker-ssl-cumplimiento/","section":"Posts","summary":"Qué es el ENS y por qué importa # El Esquema Nacional de Seguridad (ENS) es el marco normativo de ciberseguridad obligatorio para las Administraciones Públicas españolas y para las empresas privadas que prestan servicios a estas. Está regulado por el Real Decreto 311/2022 y establece los principios, requisitos y medidas de seguridad que deben aplicar los sistemas de información que manejan datos o servicios públicos.\n","title":"Wazuh SIEM con Docker: despliegue completo con SSL y reglas de cumplimiento","type":"posts"},{"content":"","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/tags/bases-de-datos/","section":"Tags","summary":"","title":"Bases-De-Datos","type":"tags"},{"content":" El problema real # Hace poco me di cuenta de que había pusheado un archivo .env con credenciales de API a un repositorio privado. Aunque fuera privado, no es excusa. Un acceso comprometido, un repositorio que se vuelve público, o simplemente una auditoría de seguridad hubiera expuesto mis tokens. Aprendí que no puedo confiar en eliminar archivos en commits posteriores—Git mantiene todo el historial.\nPor qué git-filter-repo # Hace años hubiera usado git filter-branch, pero es lento y propenso a errores. git-filter-repo es la herramienta moderna recomendada por los mantenedores de Git. Es rápido, preciso y tiene mejores opciones para este trabajo.\nInstalación # En mi servidor Debian:\napt-get install git-filter-repo O con pip:\npip3 install git-filter-repo Paso 1: Identificar los daños # Primero, necesito saber qué commits contienen credenciales. Busco patrones sospechosos:\ngit log --all --oneline | head -20 git log -p --all | grep -i \u0026#34;token\\|password\\|api_key\u0026#34; | head -10 También reviso qué archivos sensibles están en el historial:\ngit log --all --full-history -- \u0026#34;.env\u0026#34; git log --all --full-history -- \u0026#34;config.yml\u0026#34; En mi caso, encontré que .env se había commitido en 3 ocasiones y un archivo credentials.json en 2.\nPaso 2: Hacer backup # Nunca hago esto sin backup:\ncd /path/to/my/repo git clone --mirror . backup-mirror.git Si algo sale mal, tengo una copia completa del repositorio con todo su historial.\nPaso 3: Limpiar archivos específicos # Ejecuto git-filter-repo para eliminar los archivos sensibles del historial completo:\ngit-filter-repo --invert-paths --path .env --path credentials.json El parámetro --invert-paths significa que mantiene todo EXCEPTO lo que especifico. Esto es lo contrario a lo que parece, pero funciona perfectamente.\nEl proceso tarda algunos segundos y reescribe todo el historial. Al final, veo:\nProcessed 47 commits New history has 47 commits Paso 4: Forzar el push (con cuidado) # Como he reescrito el historial, necesito hacer un push forzado. En un servidor doméstico donde soy el único dev, es seguro:\ngit push origin --force --all git push origin --force --tags Si es un repositorio compartido, coordino con el equipo para que todos hagan git reset --hard origin/main después.\nPaso 5: Rotar credenciales comprometidas # Las credenciales que estaban en Git ya no están, pero debo asumir que fueron comprometidas. Roto todos los tokens:\nAPI Keys: Revoco las antiguas en el panel de la API y genero nuevas Contraseñas: Cambio la contraseña de cualquier servicio que usaba esas credenciales Tokens de BD: Regenero credenciales de bases de datos SSH Keys: Si estaban expuestas, genero nuevas parejas Documento estos cambios en un archivo privado (no en 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 Paso 6: Prevención futura # Agrego reglas a .gitignore (ahora está limpio):\n.env .env.local credentials.json secrets/ También configuro un hook pre-commit para detectar patrones peligrosos:\ngit config core.hooksPath .githooks Y creo .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 Conclusión # La limpieza toma 10 minutos. La rotación de credenciales, otro rato. Pero es tiempo bien invertido. En un servidor doméstico, no tengo excusa para ser negligente con secretos. Ahora uso variables de entorno y archivos locales que nunca entran a Git.\nLección aprendida: Los secretos nunca van en control de versiones, ni siquiera \u0026ldquo;privado\u0026rdquo;. Punto.\n## Equipamiento recomendado - **[YubiKey 5 NFC](https://amzn.to/4dojPNk)** — Llave de seguridad física para SSH y GitLab — elimina el riesgo de tokens robados - **[Disco duro externo 2TB](https://amzn.to/49gxgwl)** — Backup de repositorios antes de operaciones destructivas como git-filter-repo *Enlaces de afiliado. Sin coste extra para ti.* ","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/posts/como-limpiar-credenciales-expuestas-en-git-con-git-filter-repo-y-rotar-tokens/","section":"Posts","summary":"El problema real # Hace poco me di cuenta de que había pusheado un archivo .env con credenciales de API a un repositorio privado. Aunque fuera privado, no es excusa. Un acceso comprometido, un repositorio que se vuelve público, o simplemente una auditoría de seguridad hubiera expuesto mis tokens. Aprendí que no puedo confiar en eliminar archivos en commits posteriores—Git mantiene todo el historial.\n","title":"Cómo limpiar credenciales expuestas en Git con git-filter-repo y rotar tokens","type":"posts"},{"content":" El problema # Necesitaba acceder a mi servidor doméstico por SSH desde el móvil sin exponerlo directamente a internet. Las opciones obvias eran malas: abrir el puerto 22 al mundo es suicida, y confiar en apps de terceros con acceso root no me convencía. La solución que funcionó: WireGuard + Termius.\nPor qué esta combinación # WireGuard es ligero, rápido y consume poca batería en móviles. Termius es un cliente SSH pulido que maneja bien las claves privadas. Juntos, tienes acceso seguro sin complicaciones.\nPaso 1: Instalación y configuración de WireGuard en el servidor # Instalé WireGuard en mi servidor (Debian 12):\nsudo apt update sudo apt install wireguard wireguard-tools Generé las claves pública y privada del servidor:\ncd /etc/wireguard sudo wg genkey | tee privatekey | wg pubkey \u0026gt; publickey Creé el archivo de configuración /etc/wireguard/wg0.conf:\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 Activé el servicio:\nsudo systemctl enable wg-quick@wg0 sudo systemctl start wg-quick@wg0 Abrí el puerto UDP 51820 en el firewall (en mi caso, el router):\nsudo ufw allow 51820/udp Paso 2: Configuración del cliente en el móvil # Instalé WireGuard desde la Play Store (Android) o App Store (iOS).\nGeneré las claves del móvil en el servidor:\nwg genkey | tee mobile_privatekey | wg pubkey \u0026gt; mobile_publickey Creé el archivo de configuración para el móvil:\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 Exporté este archivo como QR o lo pasé por USB al móvil. WireGuard lo importa directamente.\nActivé la conexión en WireGuard del móvil y verifiqué conectividad:\nwg show Paso 3: Configuración de SSH en Termius # En Termius creé una nueva conexión:\nHost: 10.0.0.1 (la IP interna del servidor en WireGuard) Puerto: 22 (SSH estándar, no necesita estar abierto al exterior) Usuario: mi usuario habitual Autenticación: Clave privada SSH Importé mi clave privada SSH desde los archivos del móvil. Termius la maneja sin exponer archivos.\nPaso 4: Pruebas y ajustes # Conecté a WireGuard desde el móvil. Abrí Termius y me conecté al servidor. Funcionó a la primera.\nLa latencia es imperceptible. El consumo de batería de WireGuard es mínimo (apenas 2-3% en 8 horas standby).\nDetalles de seguridad que importan # El servidor SSH nunca está expuesto a internet WireGuard usa criptografía moderna (Noise protocol) Las claves privadas nunca viajan por la red El tráfico SSH dentro del túnel está doblemente encriptado Lo que cambiaría # Nada. Este setup lleva meses funcionando sin problemas. La única mejora sería usar direcciones DNS dinámicas si mi IP pública cambia, pero eso es otro artículo.\nActualización: Este mismo método funciona para conectar otros dispositivos (laptop, tablet). Solo genera nuevas claves y añade más peers en WireGuard.\nEquipamiento recomendado # TECLAST T65 Tablet 13.4\u0026quot; Android 16 con teclado y lápiz — Tablet con 4G LTE como cliente SSH/VPN portable desde cualquier lugar Router GL.iNet MT3000 — Router con WireGuard integrado para montar el túnel VPN en minutos Soporte plegable para portátil de aluminio con ángulo ajustable — Ergonomía imprescindible si usas tablet o portátil para gestionar tu servidor Enlaces de afiliado. Sin coste extra para ti.\n","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/posts/conectar-el-movil-a-ssh-desde-cualquier-lugar-con-wireguard-y-termius/","section":"Posts","summary":"El problema # Necesitaba acceder a mi servidor doméstico por SSH desde el móvil sin exponerlo directamente a internet. Las opciones obvias eran malas: abrir el puerto 22 al mundo es suicida, y confiar en apps de terceros con acceso root no me convencía. La solución que funcionó: WireGuard + Termius.\nPor qué esta combinación # WireGuard es ligero, rápido y consume poca batería en móviles. Termius es un cliente SSH pulido que maneja bien las claves privadas. Juntos, tienes acceso seguro sin complicaciones.\n","title":"Conectar el móvil a SSH desde cualquier lugar con WireGuard y Termius","type":"posts"},{"content":"","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/tags/credenciales/","section":"Tags","summary":"","title":"Credenciales","type":"tags"},{"content":"","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/tags/listmonk/","section":"Tags","summary":"","title":"Listmonk","type":"tags"},{"content":" El problema inicial # Tenía Grafana y Listmonk corriendo en contenedores Docker, cada uno con su propia instancia de PostgreSQL embebida. Esto funcionaba, pero era ineficiente: dos motores de BD consumiendo recursos y sin forma centralizada de hacer backups. Decidí consolidar todo en una única instancia PostgreSQL compartida.\nPreparación: levantar PostgreSQL central # Lo primero fue crear el servidor PostgreSQL que seria el punto central. Lo hice con un docker-compose dedicado:\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 Ejecuté docker-compose up -d y verifiqué que funcionara con docker exec postgres-central psql -U admin -d default_db -c \u0026quot;\\l\u0026quot;.\nCrear bases de datos para cada servicio # Accedí al contenedor PostgreSQL y creé las BD que necesitaría:\ndocker exec -it postgres-central psql -U admin -d default_db Una vez dentro:\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; Exportar datos de las BD antiguas # Antes de mover nada, hice un dump de las BD existentes. Para Grafana:\ndocker exec grafana-container pg_dump -U grafana -d grafana \u0026gt; grafana_backup.sql Para Listmonk:\ndocker exec listmonk-container pg_dump -U listmonk -d listmonk \u0026gt; listmonk_backup.sql Importar datos en PostgreSQL central # Importé los backups en las nuevas BD:\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 Verifiqué que las tablas estuvieran presentes con \\dt en cada BD.\nActualizar Grafana # Modifiqué el docker-compose de Grafana para que apunte a PostgreSQL central:\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 Importante: la red central-net debe ser external: true porque ya existe en el docker-compose de PostgreSQL.\nActualizar Listmonk # Lo mismo para Listmonk. Su config en 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 y limpieza # Levanté ambos contenedores: docker-compose up -d. Verifiqué que Grafana y Listmonk iniciaran correctamente y que sus datos estuvieran intactos.\nUna vez confirmado todo funcionaba, eliminé los volúmenes de las BD antiguas:\ndocker volume rm grafana_postgres_data listmonk_postgres_data Beneficios reales # Ahora tengo un único punto de respaldo, menos consumo de RAM, y puedo escalar más fácilmente. Un problema: asegurate de que PostgreSQL central este en la red correcta o que los servicios puedan comunicarse por host externo.\nLa migración me tomó una hora. Sin estrés.\nEquipamiento recomendado # SSD NVMe 1TB — Mejora el rendimiento del servidor con base de datos Mini PC Intel N100 — Servidor doméstico silencioso y eficiente para correr Docker y PostgreSQL 24/7 SAI/UPS 600VA — Protege el servidor y la base de datos de cortes de luz Enlaces de afiliado. Sin coste extra para ti.\n","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/posts/migrar-grafana-y-listmonk-a-postgresql-centralizado-en-docker/","section":"Posts","summary":"El problema inicial # Tenía Grafana y Listmonk corriendo en contenedores Docker, cada uno con su propia instancia de PostgreSQL embebida. Esto funcionaba, pero era ineficiente: dos motores de BD consumiendo recursos y sin forma centralizada de hacer backups. Decidí consolidar todo en una única instancia PostgreSQL compartida.\nPreparación: levantar PostgreSQL central # Lo primero fue crear el servidor PostgreSQL que seria el punto central. Lo hice con un docker-compose dedicado:\n","title":"Migrar Grafana y Listmonk a PostgreSQL centralizado en Docker","type":"posts"},{"content":"","date":"19 de mayo de 2026","externalUrl":null,"permalink":"/tags/m%C3%B3vil/","section":"Tags","summary":"","title":"Móvil","type":"tags"},{"content":"Ya tenía backups con rsync y cron, pero rsync copia archivos, no snapshots. Si borras accidentalmente un fichero y el backup se sincroniza antes de que te des cuenta, lo pierdes. Restic resuelve eso y añade algo que rsync nunca dará: cifrado, deduplicación y snapshots con historial navegable.\nQué hace Restic diferente # Característica rsync Restic Cifrado AES-256 No Sí Deduplicación No Sí (a nivel de bloque) Snapshots navegables No Sí Múltiples backends No SFTP, S3, Backblaze, rclone… Comprobación de integridad No restic check Política de retención Manual restic forget --prune La deduplicación es especialmente útil en backups de bases de datos y directorios de configuración que cambian poco: un repositorio de Restic que lleva 6 meses de backups diarios suele ocupar mucho menos que 180 copias completas.\nInstalación # En Ubuntu/Debian, la versión del repositorio oficial suele ir retrasada. Mejor descargar el binario directamente desde las releases oficiales de GitHub:\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 O con los repositorios del sistema si no te importa la versión:\nsudo apt install restic # Debian/Ubuntu sudo dnf install restic # Fedora/RHEL Conceptos clave antes de empezar # Repositorio: el destino donde Restic guarda los backups. Puede ser una carpeta local, un servidor SFTP, un bucket S3, etc. Snapshot: cada vez que ejecutas restic backup, Restic guarda un snapshot puntual. Los snapshots comparten bloques deduplicados, por lo que no multiplican el espacio. Password: el repositorio se cifra con una contraseña. Sin ella, los datos son ilegibles. Guárdala en un gestor de contraseñas o en un fichero separado del backup. Inicializar un repositorio # Opción A: repositorio local # restic init --repo /opt/backups/mi-servidor Opción B: repositorio en servidor remoto via SFTP # restic init --repo sftp:usuario@servidor-backup:/opt/restic/mi-servidor Opción C: repositorio en S3 (o 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 En los tres casos, Restic te pedirá una contraseña para cifrar el repositorio. Guárdala bien — sin ella no puedes restaurar nada.\nFichero de variables de entorno # Para no escribir la contraseña en cada comando, crea un fichero de variables:\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 A partir de ahora, antes de cualquier comando de Restic:\nsource /etc/restic/env.sh O con variables de entorno en línea para scripts:\nenv $(cat /etc/restic/env.sh | grep export | sed \u0026#39;s/export //\u0026#39;) restic snapshots Primer 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 La salida muestra cuántos ficheros procesó, cuántos son nuevos y el espacio ahorrado por deduplicación:\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 Ver y navegar 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 Salida de 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 Restaurar datos # Restauración completa de un snapshot # # Restaura todo en /tmp/restauracion para revisar antes de mover restic restore a1b2c3d4 --target /tmp/restauracion Restaurar solo un fichero o directorio # # Restaura solo nginx.conf del snapshot más reciente restic restore latest --target /tmp/restauracion \\ --include /etc/nginx/nginx.conf Montar el repositorio como sistema de ficheros (útil para explorar) # # 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 Política de retención automática # Sin política de retención, el repositorio crece indefinidamente. Esta es la configuración que uso para backups diarios:\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 elimina físicamente los datos no referenciados. Sin él, forget solo elimina los metadatos del snapshot pero no libera espacio.\nVerificar la integridad del repositorio # # Verificación rápida de metadatos restic check # Verificación completa leyendo todos los datos (lento, hazlo mensualmente) restic check --read-data Si restic check falla, el repositorio tiene corrupción. Por eso siempre se recomienda tener al menos dos repositorios en destinos distintos (la conocida regla 3-2-1).\nAutomatizar con systemd timer # Restic se integra perfectamente con systemd timers, que permiten capturar la salida en journald y ejecutar la tarea aunque el servidor estuviera apagado a la hora programada.\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 Activar:\nsudo systemctl daemon-reload sudo systemctl enable --now restic-backup.timer # Verificar que está activo sudo systemctl list-timers restic-backup.timer RandomizedDelaySec=10min distribuye los backups en ventanas aleatorias para evitar que todos los servidores golpeen el destino SFTP o S3 al mismo tiempo.\nNotificaciones por email al terminar # Combinando con el sistema de notificaciones por email con msmtp, podemos recibir un resumen del backup. Modifica ExecStart por un script wrapper:\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 Y en el .service, cambia ExecStart y ExecStartPost por una sola línea:\nExecStart=/usr/local/bin/restic-backup.sh Comprobación semanal de integridad # Añade un segundo timer para la verificación semanal, que es más costosa:\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 Ver el estado en el log del sistema # # Ú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 Conclusión # Restic no reemplaza completamente a rsync para casos de sincronización de directorios en vivo, pero para backups con historial, es claramente superior: cifrado de serie, deduplicación transparente, y snapshots navegables sin scripts adicionales. La integración con systemd timers y el envío de notificaciones por email cierra el ciclo: sabes que el backup se ejecutó, cuándo falló, y puedes restaurar cualquier punto en el tiempo en minutos.\nLa clave real de cualquier estrategia de backups no es el software que elijas, sino verificar que puedes restaurar. Prueba restic restore en un directorio temporal antes de que lo necesites de verdad.\n","date":"11 de mayo de 2026","externalUrl":null,"permalink":"/posts/restic-backups-cifrados-deduplicacion/","section":"Posts","summary":"Ya tenía backups con rsync y cron, pero rsync copia archivos, no snapshots. Si borras accidentalmente un fichero y el backup se sincroniza antes de que te des cuenta, lo pierdes. Restic resuelve eso y añade algo que rsync nunca dará: cifrado, deduplicación y snapshots con historial navegable.\nQué hace Restic diferente # Característica rsync Restic Cifrado AES-256 No Sí Deduplicación No Sí (a nivel de bloque) Snapshots navegables No Sí Múltiples backends No SFTP, S3, Backblaze, rclone… Comprobación de integridad No restic check Política de retención Manual restic forget --prune La deduplicación es especialmente útil en backups de bases de datos y directorios de configuración que cambian poco: un repositorio de Restic que lleva 6 meses de backups diarios suele ocupar mucho menos que 180 copias completas.\n","title":"Restic: backups cifrados con deduplicación para tu servidor Linux","type":"posts"},{"content":"","date":"10 de mayo de 2026","externalUrl":null,"permalink":"/tags/claude/","section":"Tags","summary":"","title":"Claude","type":"tags"},{"content":"","date":"10 de mayo de 2026","externalUrl":null,"permalink":"/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":"","date":"10 de mayo de 2026","externalUrl":null,"permalink":"/tags/ia/","section":"Tags","summary":"","title":"IA","type":"tags"},{"content":"","date":"10 de mayo de 2026","externalUrl":null,"permalink":"/tags/productividad/","section":"Tags","summary":"","title":"Productividad","type":"tags"},{"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":"10 de mayo de 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 de mayo de 2026","externalUrl":null,"permalink":"/tags/servidor-dom%C3%A9stico/","section":"Tags","summary":"","title":"Servidor-Doméstico","type":"tags"},{"content":" Por qué necesitas esto # Hace unos meses me enfrenté a un problema común: quería acceder a mis servicios internos (Jellyfin, Home Assistant, etc.) desde fuera de casa, pero no quería exponerlos directamente en internet. Abrir puertos es un riesgo innecesario. La solución fue montar una VPN con Wireguard en Docker. Fue la mejor decisión que tomé para mi infraestructura casera.\nVentajas de Wireguard # Ligero: consume menos recursos que OpenVPN Rápido: protocolo moderno y eficiente Fácil de configurar: comparado con otras alternativas Seguro: criptografía de última generación Docker-friendly: hay imágenes oficiales excelentes Preparación # Necesitas:\nUn servidor con Docker instalado El archivo docker-compose.yml Un dominio o IP pública (para conectarte desde fuera) Los clientes Wireguard en tus dispositivos Instalación paso a paso # 1. Crear el directorio de configuración # mkdir -p ~/wireguard/config cd ~/wireguard 2. Docker Compose # Crea el archivo docker-compose.yml:\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 Reemplaza:\ntu-dominio-o-ip-publica.com con tu dirección real Los PEERS con los nombres de tus dispositivos La zona horaria según tu ubicación 3. Iniciar el contenedor # docker-compose up -d Los archivos de configuración se generarán automáticamente en ./config. Espera unos segundos y verifica:\nls -la config/peer_*/ 4. Obtener los códigos QR # Para conectar tus dispositivos:\ndocker exec wireguard cat /config/peer_telefono/peer_telefono.conf O directamente los QR:\ndocker exec wireguard qrencode -t ansiutf8 \u0026lt; /config/peer_telefono/peer_telefono.conf Escanea con tu cliente Wireguard en cada dispositivo.\nConectar servicios internos # Aquí viene lo importante. Quiero acceder a servicios en mi red interna. Para esto, modifico el docker-compose.yml y añado rutas:\nenvironment: - ALLOWEDIPS=10.0.0.0/24,192.168.1.0/24 Esto permite que desde la VPN accedas a la red 192.168.1.0/24 (tu red local).\nEn el servidor, habilita el forwarding:\necho \u0026#34;net.ipv4.ip_forward=1\u0026#34; | sudo tee -a /etc/sysctl.conf sudo sysctl -p Acceso desde clientes # Una vez conectado a la VPN, accedes a tus servicios usando sus IPs internas:\nhttp://192.168.1.100:8096 para Jellyfin http://192.168.1.50:8123 para Home Assistant Lo que necesites en tu red Mantenimiento # Renovar certificados (cada 6 meses aproximadamente):\ndocker exec wireguard /app/wireguard-tools/show-peer peer_nombre Añadir un nuevo dispositivo:\ndocker-compose down # Edita PEERS en docker-compose.yml docker-compose up -d Notas finales # Abre solo el puerto 51820/UDP en tu router Usa firewall en el servidor para bloquear acceso innecesario Verifica que el forwarding de IPs está activo Monitoriza el tráfico de la VPN regularmente Llevo varios meses con esta configuración y es totalmente estable. Accedo a mis servicios desde cualquier parte sin nervios de seguridad. Definitivamente, recomiendo este setup a cualquiera que quiera mantener su infraestructura doméstica privada pero accesible.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización TECLAST T65 Tablet 13.4\u0026quot; Android 16 con teclado y lápiz — Cliente WireGuard portable: gestiona tus servicios desde cualquier lugar Enlaces de afiliado. Sin coste extra para ti.\n","date":"6 de mayo de 2026","externalUrl":null,"permalink":"/posts/vpn-con-wireguard-en-docker-acceso-seguro-a-servicios-internos-sin-exponer-puertos/","section":"Posts","summary":"Por qué necesitas esto # Hace unos meses me enfrenté a un problema común: quería acceder a mis servicios internos (Jellyfin, Home Assistant, etc.) desde fuera de casa, pero no quería exponerlos directamente en internet. Abrir puertos es un riesgo innecesario. La solución fue montar una VPN con Wireguard en Docker. Fue la mejor decisión que tomé para mi infraestructura casera.\n","title":"VPN con Wireguard en Docker: Acceso seguro a servicios internos sin exponer puertos","type":"posts"},{"content":"","date":"5 de mayo de 2026","externalUrl":null,"permalink":"/tags/alertas/","section":"Tags","summary":"","title":"Alertas","type":"tags"},{"content":" El problema # Después de pasar meses ejecutando contenedores en mi servidor doméstico, me cansé de descubrir problemas cuando las cosas ya estaban rotas. Un contenedor consumiendo toda la memoria. Un volumen lleno sin aviso. Necesitaba visibilidad real sobre lo que ocurría en mi infraestructura.\nDecidí implementar un stack de monitorización con Prometheus y Grafana. Aquí documento exactamente cómo lo hice.\nArquitectura elegida # Prometheus: recopila métricas de Docker cAdvisor: expone métricas de contenedores Grafana: visualiza todo en dashboards Alertmanager: notifica cuando algo falla Paso 1: Docker Compose con el stack completo # Creé un archivo docker-compose.yml que levanta todo junto:\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 Paso 2: Configurar Prometheus # Archivo 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;] Paso 3: Definir las alertas # Archivo 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; Paso 4: Configurar Alertmanager # Archivo 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; Paso 5: Iniciar y verificar # docker-compose up -d Acceso:\nPrometheus: http://localhost:9090 Grafana: http://localhost:3000 cAdvisor: http://localhost:8080 Paso 6: Crear dashboards en Grafana # En Grafana importé el dashboard público 893 (Docker and Host Monitoring) que funciona directo con cAdvisor.\nResultado # Ahora tengo visibilidad completa. Recibo alertas cuando:\nUn contenedor consume más del 80% de CPU durante 2 minutos La memoria supera el 85% del límite El disco cae por debajo del 10% El setup completo ocupa menos de 500MB de RAM en reposo y me ha ahorrado ya varios sustos. Vale la pena.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"5 de mayo de 2026","externalUrl":null,"permalink":"/posts/monitorizacion-de-contenedores-docker-con-prometheus-y-grafana-alertas-automaticas-en-casa/","section":"Posts","summary":"El problema # Después de pasar meses ejecutando contenedores en mi servidor doméstico, me cansé de descubrir problemas cuando las cosas ya estaban rotas. Un contenedor consumiendo toda la memoria. Un volumen lleno sin aviso. Necesitaba visibilidad real sobre lo que ocurría en mi infraestructura.\nDecidí implementar un stack de monitorización con Prometheus y Grafana. Aquí documento exactamente cómo lo hice.\n","title":"Monitorización de contenedores Docker con Prometheus y Grafana: alertas automáticas en casa","type":"posts"},{"content":"","date":"5 de mayo de 2026","externalUrl":null,"permalink":"/tags/prometheus/","section":"Tags","summary":"","title":"Prometheus","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/auth/","section":"Tags","summary":"","title":"Auth","type":"tags"},{"content":" El problema # Hace unos días intenté automatizar el envío de correos desde mi servidor doméstico. Nada complicado: un script Python con smtplib para enviar notificaciones. El problema llegó cuando configuré una contraseña con caracteres especiales: 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 Resultado: SMTPAuthenticationError: (535, b'5.7.8 Authentication credentials invalid')\nLo extraño es que la contraseña era correcta. Accedía manualmente sin problemas. El error 535 sugería fallos de autenticación, pero el problema real estaba en la codificación.\nPor qué falla # La culpa es del manejo de encoding en smtplib. El método login() utiliza por defecto UTF-8, pero luego aplica transformaciones que no siempre respetan los caracteres especiales correctamente, especialmente cuando el servidor o la librería tienen configuraciones legadas.\nEn mi caso, el servidor SMTP esperaba una codificación UTF-8 consistente. Cuando smtplib procesaba la contraseña con $ y Ñ, algo en el camino se corrompía.\nLa solución: AUTH LOGIN manual # El protocolo AUTH LOGIN es simple: se codifican usuario y contraseña en base64 por separado y se envían en pasos manuales. Esto te da control total sobre la codificación.\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;) Desglose del protocolo # AUTH LOGIN: El cliente solicita usar el mecanismo AUTH LOGIN Usuario en base64: El servidor responde con 334, espera el usuario codificado Contraseña en base64: El servidor responde con 334, espera la contraseña codificada Respuesta 235: Indica autenticación exitosa El método docmd() envía comandos SMTP crudos y devuelve el código de respuesta y el mensaje.\nPrueba en mi servidor # Implementé esto en mi infraestructura doméstica con Postfix. La diferencia es notable:\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 Consideraciones de seguridad # Base64 no es cifrado: usa siempre STARTTLS o conexión directa a puerto 465 con SSL El encoding UTF-8 es seguro para caracteres especiales Este método es compatible con cualquier servidor SMTP que soporte AUTH LOGIN Script completo # 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() Conclusión # Si tu servidor SMTP rechaza contraseñas con caracteres especiales, no es un misterio. Es un problema de codificación. Implementar AUTH LOGIN manualmente te da control total y funciona con cualquier contraseña.\nLo apliqué en producción en mi servidor doméstico y no he tenido problemas desde entonces.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/auth-login-manual-en-python-con-smtplib-caracteres-especiales-y-error-535/","section":"Posts","summary":"El problema # Hace unos días intenté automatizar el envío de correos desde mi servidor doméstico. Nada complicado: un script Python con smtplib para enviar notificaciones. El problema llegó cuando configuré una contraseña con caracteres especiales: MiPasw0rd$Ñ.\nimport smtplib server = smtplib.SMTP('mail.example.com', 587) server.starttls() server.login('usuario@example.com', 'MiPasw0rd$Ñ') # Error 535 Resultado: SMTPAuthenticationError: (535, b'5.7.8 Authentication credentials invalid')\nLo extraño es que la contraseña era correcta. Accedía manualmente sin problemas. El error 535 sugería fallos de autenticación, pero el problema real estaba en la codificación.\n","title":"AUTH LOGIN manual en Python con smtplib: caracteres especiales y error 535","type":"posts"},{"content":" El problema # Hace poco perdí un disco duro sin previo aviso. No fue catastrófico porque tenía backups, pero me hizo consciente de que muchos hobbistas con servidores domésticos no tienen ninguna estrategia de protección de datos. Si tu servidor Docker se cae mañana, ¿cuánto tiempo tardarías en recuperarlo?\nEn este artículo comparto cómo automaticé los backups de mi infraestructura Docker usando rsync y cron. Es simple, eficiente y funciona.\nLa estrategia # Mi enfoque es straightforward:\nrsync para sincronizar incrementalmente solo lo que cambió cron para automatizar la ejecución diaria Un disco externo USB como destino de backup Retención de múltiples snapshots para recuperación granular Esto no es backup en cloud. Es backup local, rápido y bajo mi control.\nConfiguración paso a paso # 1. Preparar el almacenamiento # Conecté un disco USB y lo monté en /mnt/backup. Verificá que esté disponible:\nlsblk mount | grep backup 2. Script de backup # Creé /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 Hacerlo ejecutable:\nchmod +x /usr/local/bin/docker-backup.sh 3. Configurar cron # Edité la tabla cron del usuario root:\nsudo crontab -e Agregué esta línea para ejecutar a las 2 AM todos los días:\n0 2 * * * /usr/local/bin/docker-backup.sh Para verificar que está registrado:\nsudo crontab -l 4. Monitoreo # Creé un segundo script para alertarme si algo falla. En /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 Lo ejecuto manualmente cada semana o via cron si quiero:\nchmod +x /usr/local/bin/check-backup.sh /usr/local/bin/check-backup.sh Consideraciones importantes # Espacio en disco: rsync con --delete sincroniza exactamente el origen. Reviso que el destino tenga al menos 1.5x el tamaño de los datos Docker.\nPermisos: El script corre como root, así que puede acceder a /var/lib/docker. Si usas un usuario regular, necesitarás permisos especiales.\nPruebas: Una vez al mes simulo una recuperación restaurando un archivo aleatorio en una máquina de prueba. Un backup que nunca se probó no existe.\nCifrado: Mi disco USB está en casa conmigo, así que no lo cifro. Si lo mantuvieras en otro lugar, considera --backup-dir con sincronización a un destino cifrado.\nResultado # Ahora duermo mejor. Cada noche a las 2 AM, Docker, las configuraciones y los datos se sincronizan automáticamente. Si el servidor muere, recupero todo en 30 minutos.\nLa clave es: automatización simple, verificación manual. No esperes a que falle algo para probar tu backup.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/backups-automaticos-con-rsync-y-cron-para-docker-domestico/","section":"Posts","summary":"El problema # Hace poco perdí un disco duro sin previo aviso. No fue catastrófico porque tenía backups, pero me hizo consciente de que muchos hobbistas con servidores domésticos no tienen ninguna estrategia de protección de datos. Si tu servidor Docker se cae mañana, ¿cuánto tiempo tardarías en recuperarlo?\nEn este artículo comparto cómo automaticé los backups de mi infraestructura Docker usando rsync y cron. Es simple, eficiente y funciona.\n","title":"Backups automáticos con rsync y cron para Docker doméstico","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/disaster-recovery/","section":"Tags","summary":"","title":"Disaster-Recovery","type":"tags"},{"content":" El problema que nadie ve # Hace dos meses desplegué una API FastAPI en producción y el servidor se comportaba extraño. CPU estable en 40-50% sin motivo aparente. Pensé que era un leak de memoria, que eran los logs, que era la base de datos. Era --reload.\nResulta que copiar-pegar el comando de desarrollo directamente al contenedor Docker es más común de lo que debería ser. Y sí, uvicorn con --reload funciona. El servidor responde. Los requests van rápido. Pero hay un coste que no ves hasta que tienes 10K requests diarios.\nQué es exactamente el file-watcher # El flag --reload de uvicorn inicia un proceso adicional que monitoriza todos los ficheros Python de tu proyecto. No solo tu código. Todos.\nCuando activas --reload, uvicorn:\nInicia un process manager (watchfiles por defecto) Cada X segundos (por defecto 0.4s) escanea todos los .py del directorio Calcula checksums o hashes de cada fichero Si detecta cambios, reinicia el worker completo Mientras tanto, sigue escaneando en cada ciclo, incluso sin cambios Este escaneo no es gratis. En un proyecto mediano con 200 ficheros Python distribuidos en vendor, librerías y módulos propios, cada ciclo de watchfiles toca disco y CPU.\nCómo consume CPU en cada request # La parte criminal es que el file-watcher no se pausa durante los requests. Mientras tu API está procesando una solicitud:\nEl monitor sigue escaneando ficheros en background Compite por I/O de disco con tu aplicación En contenedores, si no tienes límites de recursos, puede llegar a consumir más CPU que la propia lógica del request Medí esto en mi servidor. Con un request simple a una endpoint que tarda 50ms:\nSin --reload:\nCPU: 2-3% por request I/O wait: \u0026lt;1% Con --reload:\nCPU: 8-12% por request I/O wait: 3-5% No parece mucho en un request aislado. Pero con 100 requests concurrentes, ese overhead se multiplica.\nCómo detectarlo en tu servidor # 1. Mira los procesos en ejecución # ps aux | grep uvicorn Si ves dos procesos uvicorn (o un uvicorn + watchfiles), tienes --reload activo.\n2. Revisa tu comando de inicio # # 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. Monitoriza CPU durante un test de carga # # Terminal 1: corre tu servidor docker run -it tu-contenedor # Terminal 2: genera carga ab -n 1000 -c 10 http://localhost:8000/endpoint Observa top o docker stats. Si ves picos inexplicables, sospecha de --reload.\n4. Revisa los logs de uvicorn # Con --reload activo, verás mensajes como:\nINFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) INFO: Will watch for changes in these directories: ... Si ves \u0026ldquo;Will watch for changes\u0026rdquo;, tienes un problema.\nLa solución (es obvia, pero importante) # En Docker, asegúrate de que tu Dockerfile NO usa --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;] Para desarrollo local, usa --reload sin culpa:\nuvicorn main:app --reload Lo que aprendí # El file-watcher de uvicorn es excelente para desarrollo. Es transparente, funciona bien y acelera el ciclo. Pero en producción es como dejar el ordenador escaneando antivirus continuamente.\nRevisé mis otros contenedores después de esto. Encontré tres más con --reload activo. Después de quitarlo, el consumo de CPU bajó entre 30-45%.\nEs uno de esos bugs que no es un bug. Tu aplicación funciona. Los requests se procesan. Pero tu servidor está haciendo trabajo invisible que no necesita hacer.\nPróxima vez antes de desplegar a producción, grep por --reload. Te ahorrará una sesión de troubleshooting.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/el-coste-oculto-de---reload-en-uvicorn-que-consume-cpu-realmente-en-produccion/","section":"Posts","summary":"El problema que nadie ve # Hace dos meses desplegué una API FastAPI en producción y el servidor se comportaba extraño. CPU estable en 40-50% sin motivo aparente. Pensé que era un leak de memoria, que eran los logs, que era la base de datos. Era --reload.\nResulta que copiar-pegar el comando de desarrollo directamente al contenedor Docker es más común de lo que debería ser. Y sí, uvicorn con --reload funciona. El servidor responde. Los requests van rápido. Pero hay un coste que no ves hasta que tienes 10K requests diarios.\n","title":"El coste oculto de --reload en uvicorn: qué consume CPU realmente en producción","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/encoding/","section":"Tags","summary":"","title":"Encoding","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/excel/","section":"Tags","summary":"","title":"Excel","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/fastapi/","section":"Tags","summary":"","title":"Fastapi","type":"tags"},{"content":"He estado trabajando con hojas de cálculo Excel que llegan de diferentes departamentos. Cada una usa nombres de columna distintos, los datos están sucios (teléfonos con notas, NIFs mezclados con texto), y los códigos tienen formatos inconsistentes. Aquí documento la solución que construí.\nEl problema real # Recibía archivos Excel donde:\nColumnas llamadas \u0026ldquo;NIF\u0026rdquo; en uno, \u0026ldquo;CIF\u0026rdquo; en otro, \u0026ldquo;Identificación\u0026rdquo; en el tercero Teléfonos como \u0026ldquo;123-456-7890 (ext 5)\u0026rdquo;, \u0026ldquo;9876543210 - no disponible\u0026rdquo; NIFs con guiones, espacios y letras variadas Códigos de producto con prefijos inconsistentes No podía esperar que cada persona formateara igual. Necesitaba un sistema que fuera flexible.\nArquitectura de la solución # Mi enfoque usa tres capas:\nDetección de columnas por regex (encuentra \u0026ldquo;nif\u0026rdquo;, \u0026ldquo;cif\u0026rdquo;, \u0026ldquo;identificaci\u0026rdquo; con fuzzy matching) Limpiadores especializados para cada tipo de dato Validación y logging para auditoría Implementación paso a paso # 1. Cargar el archivo y detectar columnas # 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. Limpiar y validar 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. Extraer teléfonos \u0026ldquo;limpios\u0026rdquo; # 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. Normalizar códigos con prefijos variables # 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. Procesar el archivo completo # 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 Uso en producción # 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;) Lecciones aprendidas # Los regex son el patrón de oro para datos sucios Siempre devuelve el dato original + limpio para auditoría Log de errores específicos por fila facilita debugging La detección flexible de columnas ahorró horas de soporte Este sistema lleva 6 meses en producción. He tenido que añadir solo 2 limpiadores más. La clave es mantener cada limpiador independiente.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/importacion-inteligente-de-excel-en-python-deteccion-flexible-de-columnas-y-limpieza-de-datos-heterogeneos/","section":"Posts","summary":"He estado trabajando con hojas de cálculo Excel que llegan de diferentes departamentos. Cada una usa nombres de columna distintos, los datos están sucios (teléfonos con notas, NIFs mezclados con texto), y los códigos tienen formatos inconsistentes. Aquí documento la solución que construí.\nEl problema real # Recibía archivos Excel donde:\nColumnas llamadas “NIF” en uno, “CIF” en otro, “Identificación” en el tercero Teléfonos como “123-456-7890 (ext 5)”, “9876543210 - no disponible” NIFs con guiones, espacios y letras variadas Códigos de producto con prefijos inconsistentes No podía esperar que cada persona formateara igual. Necesitaba un sistema que fuera flexible.\n","title":"Importación inteligente de Excel en Python: Detección flexible de columnas y limpieza de datos heterogéneos","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/ntlm/","section":"Tags","summary":"","title":"Ntlm","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/openpyxl/","section":"Tags","summary":"","title":"Openpyxl","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/performance/","section":"Tags","summary":"","title":"Performance","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/procesamiento-datos/","section":"Tags","summary":"","title":"Procesamiento-Datos","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/producci%C3%B3n/","section":"Tags","summary":"","title":"Producción","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/regex/","section":"Tags","summary":"","title":"Regex","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/servidor/","section":"Tags","summary":"","title":"Servidor","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/sincronizaci%C3%B3n/","section":"Tags","summary":"","title":"Sincronización","type":"tags"},{"content":" El problema # Hace poco necesitaba sincronizar un share SMB desde un NAS con mi servidor Linux. La solución obvia sería smbclient o mount -t cifs, pero quería:\nSincronización incremental (solo archivos nuevos o modificados) Detectar archivos eliminados del share Controlar la autenticación NTLM directamente desde código Silenciar la cantidad obscena de logs que suelta smbprotocol La librería smbprotocol de Python resolvía todo esto, pero no está documentado cómo hacerlo bien. Aquí está mi solución.\nSetup inicial # Instala las dependencias:\npip install smbprotocol sqlalchemy pydantic python-dotenv La idea base: mantener una BD SQLite con un registro de todos los ficheros sincronizados (nombre, hash MD5, timestamp). Cada ejecución compara el share actual con la BD y procesa solo cambios.\nSilenciar los logs de smbprotocol # Esto es crítico. Sin controlarlo, la librería te llena la consola de mensajes de depuración:\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) Esto reduce los logs a lo razonable. Sin esto, cada operación genera 50 líneas de basura.\nEstructura de la BD SQLite # 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) Conexión con NTLM # 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 se negocia automáticamente. No necesitas hacer nada especial, pero asegúrate de usar el formato DOMINIO\\usuario correcto.\nSincronización incremental # 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;)) Automatización con cron # 0 */4 * * * /usr/bin/python3 /opt/sync_smb/sync.py \u0026gt;\u0026gt; /var/log/smb_sync.log 2\u0026gt;\u0026amp;1 Esto sincroniza cada 4 horas.\nConclusión # Con este setup procesas solo cambios, controlas la autenticación NTLM sin trucos raros, y tienes logs legibles. La BD SQLite es eficiente incluso con miles de archivos.\nHe usado esto en producción durante meses sin problemas.\nEquipamiento recomendado # Mini PC Intel N100 — Mini PC silencioso y eficiente para servidor doméstico 24/7 Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/sincronizacion-incremental-desde-smb-con-smbprotocol-en-linux-autenticacion-ntlm-y-control-de-logs/","section":"Posts","summary":"El problema # Hace poco necesitaba sincronizar un share SMB desde un NAS con mi servidor Linux. La solución obvia sería smbclient o mount -t cifs, pero quería:\nSincronización incremental (solo archivos nuevos o modificados) Detectar archivos eliminados del share Controlar la autenticación NTLM directamente desde código Silenciar la cantidad obscena de logs que suelta smbprotocol La librería smbprotocol de Python resolvía todo esto, pero no está documentado cómo hacerlo bien. Aquí está mi solución.\n","title":"Sincronización incremental desde SMB con smbprotocol en Linux: autenticación NTLM y control de logs","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/smb/","section":"Tags","summary":"","title":"Smb","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/smbprotocol/","section":"Tags","summary":"","title":"Smbprotocol","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/smtplib/","section":"Tags","summary":"","title":"Smtplib","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/sqlite/","section":"Tags","summary":"","title":"Sqlite","type":"tags"},{"content":" Por qué dejé cron # Llevo años usando cron en mis servidores. Es simple, confiable y funciona. Pero hace poco descubrí systemd timers y no vuelvo atrás. La razón principal: logs integrados en journald, sin archivos .log dando vueltas por el sistema, y mejor control sobre qué pasa cuando el servidor arranca o reinicia.\nEn mi caso específico, tenía un backup que no se ejecutaba si el servidor estaba apagado a la hora programada. Con cron, simplemente se perdía. Con systemd timers y Persistent=true, la tarea se ejecuta en cuanto el servidor se enciende.\nEstructura básica: .service + .timer # Systemd necesita dos archivos:\nEl servicio (/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 El 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 Luego:\nsudo systemctl daemon-reload sudo systemctl enable --now mibackup.timer OnCalendar: la sintaxis que necesitas # OnCalendar es el cron de systemd, pero más legible:\ndaily → todos los días a medianoche weekly → cada lunes a medianoche hourly → cada hora *-*-* 03:00:00 → todos los días a las 3 AM Mon *-*-* 14:30:00 → cada lunes a las 14:30 *-01,04,07,10-01 00:00:00 → primero de cada trimestre Puedes combinar múltiples líneas OnCalendar:\nOnCalendar=*-*-* 03:00:00 OnCalendar=*-*-* 15:00:00 Esto ejecuta la tarea dos veces al día.\nPersistent=true: el cambio que me convenció # Por defecto, si tu servidor está apagado cuando se programa una tarea, systemd simplemente la ignora. Con Persistent=true, systemd remembers y ejecuta la tarea la próxima vez que arranque.\nEn mi servidor doméstico, esto es crítico. No siempre está encendido, y necesito garantizar que mis backups se ejecuten aunque hayan pasado horas.\n[Timer] OnCalendar=daily Persistent=true Type=oneshot: para tareas que terminan # El parámetro Type=oneshot en el .service indica que el proceso terminará. Es lo normal para scripts de backup, sincronización, etc.\nSi usas Type=simple (el default), systemd espera que el proceso se mantenga ejecutándose. No es lo que queremos aquí.\nVer logs sin archivos externos # Aquí está lo mejor: olvídate de \u0026gt;\u0026gt; /var/log/mibackup.log.\nLos logs van directo a 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 Dentro de tu script puedes loguear con echo o logger:\n#!/bin/bash logger \u0026#34;Iniciando backup...\u0026#34; /backup/script.sh logger \u0026#34;Backup completado\u0026#34; Todo se captura automáticamente.\nChecklist de lo que hicimos # ✅ Creaste un .service con Type=oneshot ✅ Creaste un .timer con OnCalendar y Persistent=true ✅ Recargaste systemd y activaste el timer ✅ Verificas logs con journalctl, sin archivos de log extra Consejo final # Antes de depender de un timer, pruébalo manualmente:\n# Ejecutar el servicio ahora sudo systemctl start mibackup.service # Ver qué pasó journalctl -u mibackup.service -n 20 Eso. Systemd timers no es la panacea, pero para tareas programadas en servidores modernos, es superior a cron en casi todos los aspectos. Los logs centralizados en journald, combinados con Persistent=true, hacen que sea imposible no recomendarlo.\nEn mi servidor doméstico, todos los backups, limpiezas de caché y sincronizaciones de datos usan timers. Cero archivos de log sueltos. Todo integrado.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Mini PC Intel N100 — Mini PC silencioso y eficiente para servidor doméstico 24/7 Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/systemd-timers-la-alternativa-moderna-a-cron-que-necesitabas/","section":"Posts","summary":"Por qué dejé cron # Llevo años usando cron en mis servidores. Es simple, confiable y funciona. Pero hace poco descubrí systemd timers y no vuelvo atrás. La razón principal: logs integrados en journald, sin archivos .log dando vueltas por el sistema, y mejor control sobre qué pasa cuando el servidor arranca o reinicia.\nEn mi caso específico, tenía un backup que no se ejecutaba si el servidor estaba apagado a la hora programada. Con cron, simplemente se perdía. Con systemd timers y Persistent=true, la tarea se ejecuta en cuanto el servidor se enciende.\n","title":"Systemd timers: la alternativa moderna a cron que necesitabas","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/troubleshooting/","section":"Tags","summary":"","title":"Troubleshooting","type":"tags"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/tags/uvicorn/","section":"Tags","summary":"","title":"Uvicorn","type":"tags"},{"content":" El problema real # Llevo meses ejecutando servicios en mi servidor doméstico con Docker Compose. Hace poco intenté configurar una contraseña con caracteres especiales en mi archivo .env. La contraseña era algo como Pass$word123!@. Al iniciar los contenedores, la variable llegaba vacía o malformada. Después de investigar, descubrí que Docker Compose interpretaba el $ como referencia a otra variable.\nPor qué sucede: interpolación de variables # Docker Compose interpreta el archivo .env de forma especial. Cuando encuentra un $ seguido de un nombre válido de variable, intenta sustituirlo por su valor. Si esa variable no existe, la deja vacía o genera un error silencioso.\nEjemplo del problema:\n# .env DB_PASSWORD=Pass$word123 API_KEY=sk_test_$random_key SECRET=$UNDEFINED_VAR En estos casos, Docker Compose buscará variables llamadas word123, random_key y UNDEFINED_VAR. Obviamente no las encontrará, y tus valores quedarán corruptos.\nLa solución: comillas simples # La solución más confiable es envolver los valores en comillas simples. Las comillas simples previenen la interpolación de variables en Docker Compose, exactamente como funcionan en 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; Con comillas simples, Docker Compose trata todo el contenido como texto literal. No intenta resolver referencias a variables.\nUsando las variables en docker-compose.yml # Una vez definidas correctamente en .env, usarlas en tu docker-compose.yml es normal:\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 cargará los valores desde .env e inyectará las variables correctamente en los contenedores.\nEl segundo problema: restart no recarga variables # Aquí viene lo frustrante. Después de modificar tu archivo .env, ejecutas:\ndocker-compose restart Y descubres que los contenedores siguen usando los valores antiguos. Esto sucede porque restart solo reinicia los contenedores existentes sin recrearlos. No vuelve a leer el archivo .env.\nLa solución: \u0026ndash;force-recreate # Para que Docker Compose lea nuevamente el archivo .env y aplique las nuevas variables, debes recrear los contenedores. El comando correcto es:\ndocker-compose up -d --force-recreate O si prefieres una secuencia más explícita:\ndocker-compose down docker-compose up -d La opción --force-recreate fuerza la recreación incluso si la imagen no ha cambiado. Sin ella, Docker Compose podría reutilizar contenedores existentes.\nMi flujo de trabajo actual # Después de experimentar con esto, así es como manejo las variables en mi servidor:\nDefino todo en .env con comillas simples:\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; Referencia en docker-compose.yml:\nenvironment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD} Después de modificar .env, siempre uso:\ndocker-compose up -d --force-recreate Verifico que los cambios se aplicaron:\ndocker-compose exec servicio env | grep MI_VAR Lecciones aprendidas # Los caracteres especiales $, !, @, # en valores requieren comillas simples restart solo reinicia, no recarga configuración --force-recreate es imprescindible tras modificar .env Siempre verifica que las variables se hayan cargado correctamente dentro del contenedor Estos detalles ahorraron muchas horas de debugging en mi setup doméstico. Espero que te ahorren también las tuyas.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/posts/variables-de-entorno-con-caracteres-especiales-en-docker-compose-el-problema-del-dollar-y-como-recrear-contenedores/","section":"Posts","summary":"El problema real # Llevo meses ejecutando servicios en mi servidor doméstico con Docker Compose. Hace poco intenté configurar una contraseña con caracteres especiales en mi archivo .env. La contraseña era algo como Pass$word123!@. Al iniciar los contenedores, la variable llegaba vacía o malformada. Después de investigar, descubrí que Docker Compose interpretaba el $ como referencia a otra variable.\n","title":"Variables de entorno con caracteres especiales en Docker Compose: el problema del dollar y cómo recrear contenedores","type":"posts"},{"content":"","date":"4 de mayo de 2026","externalUrl":null,"permalink":"/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":"1 de mayo de 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":"Tener un servidor expuesto a Internet sin una configuración de seguridad mínima es dejar la puerta entreabierta. En este artículo recojo los pasos que aplico en mis propios servidores para reducir la superficie de ataque sin complicar la gestión del día a día.\nSSH: la primera línea de defensa # El servicio SSH es el punto de entrada más atacado en cualquier servidor Linux. Estos son los ajustes más importantes en /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 Después de cada cambio:\nsudo systemctl reload ssh # Ubuntu/Debian sudo systemctl reload sshd # CentOS/RHEL ¿Por qué PermitRootLogin no y no prohibit-password? Aunque prohibit-password ya bloquea el acceso root por contraseña, dejar activo el login root por clave sigue siendo un riesgo: si esa clave se compromete, el atacante tiene acceso total al sistema sin necesidad de escalar privilegios.\nCambiar el puerto SSH (seguridad por oscuridad) # Cambiar el puerto por defecto (22) no es seguridad real, pero elimina el ruido de los bots de internet que escanean continuamente ese puerto:\nPort 2222 # cualquier número entre 1024 y 65535 En el router o firewall, crea la regla de NAT para redirigir el puerto externo al 22 interno, o configura UFW para aceptar el nuevo puerto.\nFirewall con UFW # UFW (Uncomplicated Firewall) simplifica la gestión de iptables:\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 Actualizaciones de seguridad automáticas # Los servidores que no se actualizan acaban siendo vulnerables. unattended-upgrades aplica parches de seguridad sin intervención manual:\nsudo apt install unattended-upgrades sudo dpkg-reconfigure unattended-upgrades Para verificar que está activo:\nsystemctl is-active unattended-upgrades El fichero de configuración está en /etc/apt/apt.conf.d/50unattended-upgrades. Por defecto solo aplica actualizaciones de seguridad, lo cual es el comportamiento correcto para producción.\nGestión de usuarios # Principio de mínimo privilegio # Cada usuario solo debe tener los permisos que necesita. Revisa periódicamente qué usuarios tienen shell activa:\ngrep -v nologin /etc/passwd | grep -v false Para deshabilitar un usuario sin eliminarlo:\nsudo usermod -s /usr/sbin/nologin usuario sudo passwd -l usuario # Bloquear contraseña sudo sin contraseña — con cabeza # Es tentador poner NOPASSWD en sudoers para evitar escribir la contraseña, pero limítalo a los comandos específicos que lo necesiten:\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 Protección contra fuerza bruta con fail2ban # fail2ban monitoriza los logs del sistema y bloquea automáticamente IPs que realizan demasiados intentos fallidos:\nsudo apt install fail2ban Configuración básica en /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 Auditoría: qué revisar periódicamente # Una vez configurado el servidor, conviene revisar estos puntos con cierta frecuencia:\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 Cifrado entre servidores con WireGuard # Si tienes varios servidores que necesitan comunicarse (rsync, bases de datos, APIs internas), evita exponer esos servicios a Internet. WireGuard ofrece un túnel VPN rápido y sencillo con cifrado ChaCha20-Poly1305:\nsudo apt install wireguard wg genkey | tee privatekey | wg pubkey \u0026gt; publickey El tráfico entre servidores viaja cifrado por el túnel (10.10.0.x) en lugar de usar las IPs públicas. Así, servicios como rsync o PostgreSQL nunca quedan expuestos aunque alguien intercepte el tráfico de red.\nTengo un artículo específico sobre réplica de emergencia con rsync y WireGuard con la configuración completa.\nResumen: checklist de hardening # Acción Prioridad PermitRootLogin no en sshd_config Alta PasswordAuthentication no Alta Firewall UFW activo y reglas mínimas Alta unattended-upgrades activo Alta Cambiar puerto SSH Media AllowTcpForwarding no Media MaxAuthTries 3 Media fail2ban instalado y configurado Media Revisar usuarios con shell activa Media Auditoría periódica de puertos y SUID Baja La seguridad perfecta no existe, pero aplicar estos pasos reduce drásticamente la probabilidad de ser el objetivo fácil que los bots automatizados buscan.\nEquipamiento recomendado # Mini PC Intel N100 — Mini PC silencioso y eficiente para servidor doméstico 24/7 YubiKey 5 NFC — Llave de seguridad física para 2FA y acceso SSH seguro Enlaces de afiliado. Sin coste extra para ti.\n","date":"1 de mayo de 2026","externalUrl":null,"permalink":"/posts/hardening-servidores-linux/","section":"Posts","summary":"Tener un servidor expuesto a Internet sin una configuración de seguridad mínima es dejar la puerta entreabierta. En este artículo recojo los pasos que aplico en mis propios servidores para reducir la superficie de ataque sin complicar la gestión del día a día.\nSSH: la primera línea de defensa # El servicio SSH es el punto de entrada más atacado en cualquier servidor Linux. Estos son los ajustes más importantes en /etc/ssh/sshd_config:\n","title":"Hardening de servidores Linux: guía práctica","type":"posts"},{"content":"","date":"1 de mayo de 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":"1 de mayo de 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":"1 de mayo de 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 de mayo de 2026","externalUrl":null,"permalink":"/tags/vps/","section":"Tags","summary":"","title":"Vps","type":"tags"},{"content":" Por qué automatizar la generación de contenido # Escribir artículos técnicos lleva tiempo. Entre la configuración de servidores, los troubleshootings y la documentación, encuentro poco tiempo para redactar. Así que decidí crear un agente IA que me ayude a estructurar y generar borradores desde el terminal.\nClaude Haiku es perfecto para esto: es rápido, barato y funciona bien para tareas de generación de texto. No necesita GPUs potentes. Simplemente ejecuto un script y tengo un artículo listo para editar.\nRequisitos # Cuenta en Anthropic con acceso a la API de Claude Token de API configurado en una variable de entorno Python 3.10+ Librería anthropic instalada pip install anthropic El agente en práctica # Creé un script que recibe como entrada:\nUn tema o concepto a documentar El número de secciones deseadas El tono (técnico, didáctico, etc.) Y genera un artículo en formato Markdown listo para publicar.\nImplementación # Aquí está el script base que uso:\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: true 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() Uso en la práctica # Ejecuto el script así:\npython blog_agent.py \u0026#39;Configurar Nginx con SSL en Docker\u0026#39; 5 técnico En menos de un minuto tengo un archivo .md con un borrador completo. Luego lo reviso, corrijo detalles específicos y lo publico.\nVentajas reales # Velocidad: De tema a borrador en 1-2 minutos Consistencia: El formato siempre es coherente Punto de partida: No parto de una página en blanco Económico: Haiku es muy barato comparado con otros modelos Limitaciones # El agente genera contenido genérico. Siempre necesito agregar:\nDetalles específicos de mis configuraciones reales Comandos exactos que usé Errores que enfrenté y cómo los resolví Mi perspectiva personal Es un asistente, no un reemplazo. Pero ahorra mucho tiempo en estructura y redacción inicial.\nConclusión # Usar IA para automatizar la escritura técnica tiene sentido si la combinas con edición humana. Este agente me permite documentar experiencias más rápido sin sacrificar calidad. Si escribes frecuentemente en un blog técnico, vale la pena experimentar.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/agente-ia-con-claude-haiku-para-generar-articulos-de-blog-desde-el-terminal/","section":"Posts","summary":"Por qué automatizar la generación de contenido # Escribir artículos técnicos lleva tiempo. Entre la configuración de servidores, los troubleshootings y la documentación, encuentro poco tiempo para redactar. Así que decidí crear un agente IA que me ayude a estructurar y generar borradores desde el terminal.\nClaude Haiku es perfecto para esto: es rápido, barato y funciona bien para tareas de generación de texto. No necesita GPUs potentes. Simplemente ejecuto un script y tengo un artículo listo para editar.\n","title":"Agente IA con Claude Haiku para generar artículos de blog desde el terminal","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/autenticaci%C3%B3n/","section":"Tags","summary":"","title":"Autenticación","type":"tags"},{"content":" Por qué cambiar a autenticación por clave # Después de meses manteniendo un servidor doméstico con acceso SSH abierto, me cansé de los intentos de fuerza bruta contra la contraseña. Cambiar a autenticación por clave pública fue la mejor decisión de seguridad que tomé. Las claves son matemáticamente imposibles de craquear por fuerza bruta, mientras que las contraseñas siempre son un objetivo.\nGeneración de la clave SSH # Lo primero es generar un par de claves en tu máquina local (no en el servidor):\nssh-keygen -t ed25519 -C \u0026#34;tu_email@ejemplo.com\u0026#34; Te pedirá dónde guardar la clave. Presiona Enter para usar la ubicación por defecto (~/.ssh/id_ed25519). Luego te pedirá una frase de paso (passphrase). Yo uso una contraseña fuerte aquí, porque protege tu clave privada localmente.\nDespués de esto tendrás dos archivos:\n~/.ssh/id_ed25519 - Tu clave privada (nunca la compartas) ~/.ssh/id_ed25519.pub - Tu clave pública (esto sí va al servidor) Copiar la clave al servidor # El método más seguro es usar ssh-copy-id. Desde tu máquina local:\nssh-copy-id -i ~/.ssh/id_ed25519.pub usuario@servidor Esto añadirá tu clave pública al archivo ~/.ssh/authorized_keys en el servidor. Aún necesitarás tu contraseña para este paso.\nSi ssh-copy-id no funciona, puedes hacerlo manualmente:\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; Verificar que funciona # Antes de desactivar las contraseñas, prueba que el acceso por clave funciona:\nssh usuario@servidor Si todo está bien, deberías entrar sin que te pida contraseña (o solo la passphrase de tu clave local, si la configuraste).\nConfiguración del servidor SSH # Ahora editamos /etc/ssh/sshd_config en el servidor:\nsudo nano /etc/ssh/sshd_config Busca estas líneas y ajústalas (quita el # si está comentado):\nPubkeyAuthentication yes PasswordAuthentication no PermitEmptyPasswords no PermitRootLogin no Estas son las líneas críticas:\nPubkeyAuthentication: Activa la autenticación por clave (debe estar en yes) PasswordAuthentication: La cambias a no para desactivar contraseñas PermitEmptyPasswords: Asegura que no haya acceso sin contraseña vacía PermitRootLogin: Es una buena práctica poner esto en no Aplicar los cambios # Antes de reiniciar el servicio SSH, verifica que la configuración es válida:\nsudo sshd -t Si no devuelve errores, reinicia el servicio:\nsudo systemctl restart ssh Prueba final # Aquí viene el momento de la verdad. Abre una nueva sesión SSH sin cerrar la actual:\nssh usuario@servidor Si entras sin problemas, todo funciona. Si no, mantén la sesión anterior abierta para revertir cambios.\nBackup y checklist # Antes de hacer esto, guardo un backup de sshd_config:\nsudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup Mi checklist antes de desactivar contraseñas:\nClave SSH generada localmente Clave pública copiada al servidor Acceso por clave probado correctamente sshd -t sin errores Backup de sshd_config hecho Sesión de prueba abierta antes de reiniciar Resultado # Desde que implementé esto, los logs del servidor son tranquilos. Cero intentos de fuerza bruta exitosos. Las claves SSH son una de esas mejoras de seguridad que parece complicada al principio pero vale completamente la pena.\nEquipamiento recomendado # YubiKey 5 NFC — Llave de seguridad física para 2FA y acceso SSH seguro Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/autenticacion-ssh-por-clave-publica-desactivar-contrasenas-en-ubuntu-server/","section":"Posts","summary":"Por qué cambiar a autenticación por clave # Después de meses manteniendo un servidor doméstico con acceso SSH abierto, me cansé de los intentos de fuerza bruta contra la contraseña. Cambiar a autenticación por clave pública fue la mejor decisión de seguridad que tomé. Las claves son matemáticamente imposibles de craquear por fuerza bruta, mientras que las contraseñas siempre son un objetivo.\n","title":"Autenticación SSH por clave pública: desactivar contraseñas en Ubuntu Server","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/blog/","section":"Tags","summary":"","title":"Blog","type":"tags"},{"content":"Llevo un tiempo queriendo documentar mejor mis proyectos de infraestructura. Después de probar varias opciones, decidí montar un blog estático con Hugo. La combinación de Hugo + Blowfish resultó ser exactamente lo que necesitaba: rápido, limpio y fácil de mantener.\nPor qué Hugo y Blowfish # Hugo es un generador de sitios estáticos escrito en Go. Es increíblemente rápido y no requiere base de datos ni dependencias complicadas. Blowfish es un tema moderno, minimalista y bien documentado. Ambos se llevan bien en un servidor doméstico con recursos limitados.\nInstalación en el servidor # Lo primero fue instalar Hugo. En mi caso uso Debian en el servidor:\nsudo apt-get update sudo apt-get install hugo Verificar la instalación:\nhugo version Crear el sitio # Inicialicé el proyecto en una carpeta dentro de /home:\nhugo new site mi-blog cd mi-blog Agregar el tema Blowfish # Cloné el repositorio del tema en la carpeta de temas:\ngit clone https://github.com/nunocoracao/blowfish.git themes/blowfish Luego actualicé el archivo de configuración 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 Crear contenido # Los artículos van en la carpeta content/posts/. Cada uno es un archivo Markdown:\nhugo new posts/mi-primer-articulo.md El archivo generado incluye frontmatter YAML listo para editar:\n--- title: \u0026#34;Mi Primer Artículo\u0026#34; date: 2026-04-28 draft: false --- Contenido del artículo aquí... Servidor local para pruebas # Antes de publicar, probé todo localmente:\nhugo server -D El sitio estará disponible en http://localhost:1313/. El parámetro -D incluye borradores.\nGenerar archivos estáticos # Una vez listo, generé los archivos HTML finales:\nhugo Esto crea la carpeta public/ con todo el contenido compilado.\nServir con Nginx # Copié los archivos generados a la carpeta de Nginx:\nsudo cp -r public/* /var/www/mi-blog/ Configuré un bloque servidor en Nginx:\nserver { listen 80; server_name mi-dominio.local; root /var/www/mi-blog; index index.html; location / { try_files $uri $uri/ =404; } } Recargué Nginx:\nsudo systemctl reload nginx Automatizar las compilaciones # Para no compilar manualmente cada vez que escribo un artículo, creé un script simple:\n#!/bin/bash cd /home/usuario/mi-blog hugo sudo cp -r public/* /var/www/mi-blog/ echo \u0026#34;Blog actualizado\u0026#34; Lo guardaré como actualizar-blog.sh y le daré permisos de ejecución:\nchmod +x actualizar-blog.sh Reflexión final # Después de una semana usando esta configuración, puedo decir que es sólida. Hugo compila todo en menos de un segundo, Blowfish se ve profesional sin necesidad de customización extrema, y el servidor doméstico maneja todo sin problemas.\nLo mejor: no hay base de datos que respaldar, no hay plugins que romperse, no hay actualizaciones de seguridad cada semana. Solo archivos estáticos servidos por Nginx. Exactamente lo que buscaba.\n--- ## Equipamiento recomendado - **[Raspberry Pi 3 B+](https://amzn.to/4upmmwn)** — Servidor ligero de bajo consumo para empezar tu homelab - **[Raspberry Pi 4 (4GB)](https://amzn.to/4utrPSX)** — La base perfecta para homelab, Docker y monitorización *Enlaces de afiliado. Sin coste extra para ti.* ","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/blog-estatico-con-hugo-y-tema-blowfish-en-un-servidor-domestico/","section":"Posts","summary":"Llevo un tiempo queriendo documentar mejor mis proyectos de infraestructura. Después de probar varias opciones, decidí montar un blog estático con Hugo. La combinación de Hugo + Blowfish resultó ser exactamente lo que necesitaba: rápido, limpio y fácil de mantener.\nPor qué Hugo y Blowfish # Hugo es un generador de sitios estáticos escrito en Go. Es increíblemente rápido y no requiere base de datos ni dependencias complicadas. Blowfish es un tema moderno, minimalista y bien documentado. Ambos se llevan bien en un servidor doméstico con recursos limitados.\n","title":"Blog estático con Hugo y tema Blowfish en un servidor doméstico","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/blowfish/","section":"Tags","summary":"","title":"Blowfish","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"Ci/Cd","type":"tags"},{"content":" Introducción # Cansado de hacer deploy manual de mi blog Hugo cada vez que publico un artículo. Decidí montar un pipeline CI/CD local con GitLab Runner. El resultado: automático, confiable y sin depender de servicios externos.\nRequisitos previos # Necesitas:\nUn servidor con Docker instalado Un repositorio en GitLab (puede ser autohospedado o gitlab.com) Hugo instalado localmente para testing Acceso SSH configurado en tu servidor Instalación de GitLab Runner # Lo primero es instalar GitLab Runner en tu servidor. Yo lo hice en Docker porque ya tenía el demonio corriendo.\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 Esto monta el socket de Docker para que el runner pueda ejecutar contenedores anidados. Importante para construir imágenes.\nRegistrar el Runner # Necesitas un token de tu proyecto GitLab. Lo encuentras en: Configuración del proyecto → CI/CD → Runners\nLuego ejecutas:\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; Escoge Docker como executor. Es lo más limpio para este caso.\nConfigurar el pipeline # En la raíz de tu repositorio creas .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 Variables de entorno # En Configuración del proyecto → CI/CD → Variables, añade:\nDEPLOY_KEY: Tu clave SSH privada en base64 (cat ~/.ssh/id_rsa | base64 -w0) DEPLOY_PATH: Ruta donde quieres los archivos (yo uso /home/deploy/blog-hugo/public) Usuario de deploy # En tu servidor creas un usuario específico:\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 Configura la clave SSH pública del runner:\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 Verificar que funciona # Haz un push a la rama main:\ngit add . git commit -m \u0026#34;Test CI/CD\u0026#34; git push origin main En GitLab ves el pipeline en tiempo real. Si todo está bien, en segundos tu blog estará desplegado.\nsudo -u deploy cat /home/deploy/blog-hugo/public/index.html Notas finales # El runner local nunca sale de tu red. Total control. Los tiempos de build son rápidos porque todo está en la máquina local. Si necesitas cachear dependencias, configura volumes persistentes en Docker. He puesto restricciones a la rama main para evitar deploys accidentales. Después de tres meses funcionando sin problemas. Es simple pero efectivo.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/cicd-con-gitlab-runner-local-para-desplegar-automaticamente-un-blog-hugo/","section":"Posts","summary":"Introducción # Cansado de hacer deploy manual de mi blog Hugo cada vez que publico un artículo. Decidí montar un pipeline CI/CD local con GitLab Runner. El resultado: automático, confiable y sin depender de servicios externos.\nRequisitos previos # Necesitas:\nUn servidor con Docker instalado Un repositorio en GitLab (puede ser autohospedado o gitlab.com) Hugo instalado localmente para testing Acceso SSH configurado en tu servidor Instalación de GitLab Runner # Lo primero es instalar GitLab Runner en tu servidor. Yo lo hice en Docker porque ya tenía el demonio corriendo.\n","title":"CI/CD con GitLab Runner local para desplegar automáticamente un blog Hugo","type":"posts"},{"content":"Cuando puse mi primer sitio en producción en el servidor doméstico, cometí el error de pensar que Google lo descubriría solo. No fue así. Después de una semana sin rastro en los resultados de búsqueda, entendí que necesitaba ser más proactivo. Aquí va lo que aprendí configurando Google Search Console desde cero.\nPor qué necesitas Google Search Console # Google Search Console no es opcional. Es tu comunicación directa con Google sobre tu sitio. Te muestra errores de indexación, problemas de seguridad, y lo más importante: te permite decirle a Google exactamente qué páginas indexar y cuándo.\nSin él, dependes de que el bot de Google descubra tu sitio de forma orgánica. Con un sitio nuevo en un servidor doméstico, eso puede tardar semanas o meses.\nPaso 1: Verificar tu dominio # Entra en Google Search Console con tu cuenta de Google. Si no tienes, crea una. Es gratuito.\nHaz clic en \u0026ldquo;Agregar propiedad\u0026rdquo; y selecciona el tipo de propiedad. Tienes dos opciones:\nDominio: verifica todo el dominio (recomendado) Prefijo de URL: verifica solo una URL específica Elegí dominio porque quería cubrir todo: example.com, www.example.com, y cualquier subdominio futuro.\nVerificación por DNS # Google te dará un registro TXT para agregar a tu proveedor de dominio. En mi caso usé Namecheap.\nEl registro tiene este aspecto:\ngoogle-site-verification=ABC123XYZ... Accedí al panel de control de mi registrador, fui a DNS settings, y agregué un nuevo registro TXT con ese valor. Esperé unos minutos a que se propagara.\nVuelve a Google Search Console y haz clic en \u0026ldquo;Verificar\u0026rdquo;. Si todo está correcto, verás el mensaje de confirmación.\nConsejo: la verificación por DNS es definitiva. Google lo detectará automáticamente en futuras propiedades en el mismo dominio.\nPaso 2: Crear y optimizar tu sitemap # Un sitemap es un archivo XML que lista todas tus páginas. Google lo usa para descubrir contenido que podría perder.\nSi usas un CMS como WordPress, Astro o Next.js, probablemente ya tienes un plugin o generador. En mi caso, con un sitio estático, lo generé manualmente.\nUn sitemap básico se ve así:\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; Guardé el archivo como sitemap.xml en la raíz del servidor web (en /var/www/html/ en mi caso).\nNota importante: incluye la etiqueta \u0026lt;lastmod\u0026gt; con la fecha real. Google usa esto para saber si tu contenido cambió.\nPaso 3: Enviar el sitemap a Google # Vuelve a Google Search Console, ve a \u0026ldquo;Sitemaps\u0026rdquo; en el menú izquierdo, y pega la URL completa:\nhttps://example.com/sitemap.xml Haz clic en \u0026ldquo;Enviar\u0026rdquo;. Google lo procesará en minutos.\nDeberías ver un estado \u0026ldquo;Exitoso\u0026rdquo; con el número de URLs encontradas. Si hay errores, Google te los mostrará aquí.\nPaso 4: Optimiza el archivo robots.txt # Mientras estés aquí, asegúrate de que tu robots.txt apunta a tu sitemap:\nUser-agent: * Allow: / Disallow: /admin/ Disallow: /private/ Sitemap: https://example.com/sitemap.xml Esto le dice a Google dónde encontrar tu sitemap sin tener que adivinarlo.\nResultados # Después de enviar mi sitemap, Google indexó el 80% de mis páginas en 24 horas. En una semana estaban todas. Las búsquedas comenzaron a mostrar mis artículos.\nNo es magia, pero sí es efectivo. Google Search Console es una herramienta que todo propietario de un sitio debe usar, sin excepciones.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/como-indexar-tu-sitio-web-en-google-search-console-guia-practica-con-sitemap-y-verificacion-de-dominio/","section":"Posts","summary":"Cuando puse mi primer sitio en producción en el servidor doméstico, cometí el error de pensar que Google lo descubriría solo. No fue así. Después de una semana sin rastro en los resultados de búsqueda, entendí que necesitaba ser más proactivo. Aquí va lo que aprendí configurando Google Search Console desde cero.\nPor qué necesitas Google Search Console # Google Search Console no es opcional. Es tu comunicación directa con Google sobre tu sitio. Te muestra errores de indexación, problemas de seguridad, y lo más importante: te permite decirle a Google exactamente qué páginas indexar y cuándo.\n","title":"Cómo indexar tu sitio web en Google Search Console: guía práctica con sitemap y verificación de dominio","type":"posts"},{"content":" Introducción # Hace unos meses decidí dejar de usar servicios cloud caros y montar mi propia infraestructura en casa. La solución que encontré fue combinar Docker con Traefik. Funciona bien y ahora tengo varios servicios corriendo bajo HTTPS sin tocar manualmente un certificado. Te cuento cómo lo hice.\nQué necesitas # Un servidor con Docker instalado (cualquier máquina Linux con 2GB de RAM sobra). Un dominio propio. Un poco de paciencia con DNS. Eso es todo.\nSi no tienes un servidor dedicado, tienes opciones según presupuesto y consumo: una Raspberry Pi 3 B+ (enlace de afiliado) es perfecta para servicios ligeros con un consumo mínimo de energía. Si necesitas más potencia, un portátil como el Lenovo V15 (enlace de afiliado) es una opción muy versátil: además de servidor doméstico, tiene la capacidad para correr software industrial de marcas como Siemens (TIA Portal, SIMATIC) u otros entornos de automatización que exigen recursos reales. Un equipo, dos usos.\nEl plan # Voy a usar Traefik como reverse proxy. Maneja automáticamente los certificados Let\u0026rsquo;s Encrypt, enruta el tráfico a los contenedores correctos y sirve HTTPS sin que tengas que hacer nada una vez configurado. Es limpio y funciona.\nPaso 1: Preparar Docker Compose # Crea una carpeta para tu stack:\nmkdir -p ~/docker/traefik cd ~/docker/traefik Este será tu archivo docker-compose.yml:\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 Crea el archivo traefik.yml:\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 Crea el archivo acme.json con permisos restrictivos:\ntouch acme.json chmod 600 acme.json Paso 2: Levanta Traefik # docker-compose up -d Verifica que está corriendo:\ndocker-compose logs traefik Paso 3: Configura tu dominio # En tu proveedor DNS, apunta tu dominio (y un wildcard) a la IP pública de tu servidor:\nexample.com A TU_IP_PUBLICA *.example.com A TU_IP_PUBLICA Espera a que se propague (15 minutos típicamente).\nPaso 4: Añade tu primer servicio # Voy a añadir un ejemplo simple. Modifica el 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 Espera 30 segundos y entra en https://whoami.example.com. El certificado se genera automáticamente.\nPaso 5: Añade más servicios # Para cada servicio nuevo, solo añade labels similares a los del whoami. Traefik se encarga del resto. Es así de simple.\nConsideraciones prácticas # Backup de acme.json: Es tu archivo de certificados. Hazle backup regularmente o perderás los certificados.\nFirewall: Abre puertos 80 y 443 en tu router apuntando al servidor.\nIP dinámica: Si tu ISP cambia tu IP (común en residencial), usa un servicio DDNS.\nDashboard: Traefik tiene un dashboard en http://localhost:8080 (solo desde la máquina local por seguridad).\nProblemas comunes # Si los certificados no se generan, revisa los logs: docker-compose logs traefik. Usualmente es un problema de DNS o firewall.\nSi un servicio no responde, verifica que el label port coincida con el puerto interno del contenedor.\nConclusión # Con esta setup he montado blog, wiki, nextcloud y otros servicios en casa sin gastar en SSL o en reverse proxy comercial. Traefik es una bestia en esto. Vale mucho la pena dedicar una hora a configurarlo bien.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Lenovo V15 — Portátil versátil como servidor doméstico o para software industrial Soporte plegable para portátil de aluminio con ángulo ajustable — Ergonomía imprescindible si usas el portátil como estación de trabajo Kit de prueba de red con detector de trazas y tóner — Antes de configurar Docker y Traefik, conviene verificar el cableado físico. Ahorra horas de diagnóstico si tienes cables pasados por paredes o armarios de red Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/como-montar-tu-propia-infraestructura-web-en-casa-con-docker-y-traefik-desde-cero-hasta-https-automatico/","section":"Posts","summary":"Introducción # Hace unos meses decidí dejar de usar servicios cloud caros y montar mi propia infraestructura en casa. La solución que encontré fue combinar Docker con Traefik. Funciona bien y ahora tengo varios servicios corriendo bajo HTTPS sin tocar manualmente un certificado. Te cuento cómo lo hice.\nQué necesitas # Un servidor con Docker instalado (cualquier máquina Linux con 2GB de RAM sobra). Un dominio propio. Un poco de paciencia con DNS. Eso es todo.\n","title":"Cómo montar tu propia infraestructura web en casa con Docker y Traefik: desde cero hasta HTTPS automático","type":"posts"},{"content":" Introducción # Después de meses usando nginx manualmente, decidí cambiar a Traefik. La razón es simple: gestionar certificados SSL para cada servicio nuevo es tedioso. Traefik automatiza todo eso con Let\u0026rsquo;s Encrypt integrado. Aquí está mi configuración real.\nPor qué Traefik # Con Traefik no necesitas recargar nginx cada vez que añades un contenedor. Detecta automáticamente servicios Docker, genera certificados SSL bajo demanda y redirige el tráfico. Todo declarativo.\nEstructura base # Creo una carpeta para Traefik:\nmkdir -p /home/usuario/docker/traefik cd /home/usuario/docker/traefik Necesito tres archivos: docker-compose.yml, traefik.yml y acme.json.\nArchivo acme.json # Este archivo almacena los certificados. Debe tener permisos restrictivos:\ntouch acme.json chmod 600 acme.json Configuración de Traefik (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 Cambio mi-email@example.com por mi correo real. Let\u0026rsquo;s Encrypt lo usa para notificaciones.\nDocker Compose # Este es el archivo que levanta todo:\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 Cambio traefik.midominio.com por mi dominio real. El puerto 8080 es el del dashboard de Traefik.\nLevantar Traefik # docker-compose up -d Reviso logs:\ndocker-compose logs -f Si todo va bien, el dashboard estará en https://traefik.midominio.com.\nAñadir servicios # Aquí está lo bueno. Para añadir un servicio nuevo, solo necesito labels en Docker. Ejemplo con un contenedor simple:\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 No necesito tocar Traefik. El certificado se genera automáticamente.\nProblemas comunes # El dominio no resuelve: Asegúrate de que tu DNS apunta a la IP correcta.\nACME challenge falla: Verifica que el puerto 80 está abierto y accesible desde internet. Let\u0026rsquo;s Encrypt lo necesita.\nDashboard lento: Es normal con muchos servicios. No es un problema.\nConclusión # Traefik me ahorró horas de configuración manual. Cada contenedor nuevo solo necesita cuatro labels. Los certificados se renuevan solos 30 días antes de expirar.\nSi tienes un servidor doméstico con varios servicios, vale la pena migrarse. La curva de aprendizaje es corta y los beneficios son reales.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/configurar-traefik-v211-como-reverse-proxy-con-docker-y-https-automatico-con-lets-encrypt/","section":"Posts","summary":"Introducción # Después de meses usando nginx manualmente, decidí cambiar a Traefik. La razón es simple: gestionar certificados SSL para cada servicio nuevo es tedioso. Traefik automatiza todo eso con Let’s Encrypt integrado. Aquí está mi configuración real.\nPor qué Traefik # Con Traefik no necesitas recargar nginx cada vez que añades un contenedor. Detecta automáticamente servicios Docker, genera certificados SSL bajo demanda y redirige el tráfico. Todo declarativo.\n","title":"Configurar Traefik v2.11 como reverse proxy con Docker y HTTPS automático con Let's Encrypt","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/css/","section":"Tags","summary":"","title":"Css","type":"tags"},{"content":"CSS parece sencillo hasta que algo no funciona como esperas. Llevas horas mirando el inspector del navegador, cambias una propiedad, otra, y el problema sigue. Este artículo recoge los errores más frecuentes que me he encontrado montando este blog, con soluciones directas.\nEl fondo que desaparece al hacer scroll # El problema: La página se ve bien en la parte visible, pero al bajar aparece un fondo blanco o distinto al esperado.\nPor qué ocurre: El color de fondo está definido en un contenedor interior (como main o article), no en html o body. Cuando el contenido es más corto que la ventana, el resto de la página queda sin color.\nLa solución:\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%; } Esto garantiza que el fondo cubre desde el primer píxel hasta el último, sin importar cuánto contenido haya.\nDark mode que solo aplica a medias # El problema: Activas el modo oscuro y algunos elementos cambian, pero otros quedan con fondo claro.\nPor qué ocurre: El dark mode en frameworks como Tailwind o Blowfish funciona añadiendo la clase .dark al elemento html. Si tu CSS personalizado solo apunta a body, puede perder el color en ciertos casos.\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; } Regla práctica: cuando trabajes con dark mode basado en clases, aplica siempre el color de fondo tanto en html como en body.\nbackground-image sin background-color # El problema: Tienes un patrón SVG o imagen de fondo, pero en zonas donde la imagen no carga o tarda, aparece un fondo blanco.\nLa solución: Define siempre un background-color de fallback junto con 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; } El color actúa de red de seguridad. El usuario nunca ve un destello blanco mientras carga el SVG.\nbackground-attachment: fixed y el problema en móvil # El problema: El efecto parallax con background-attachment: fixed se ve bien en escritorio pero en móvil el fondo aparece estático, cortado o desplazado de forma extraña.\nPor qué ocurre: La mayoría de navegadores móviles ignoran fixed en elementos que no son html/body, y algunos lo implementan con bugs conocidos en iOS Safari.\nLa solución: Desactívalo en móvil:\nbody { background-attachment: fixed; } @media (max-width: 768px) { body { background-attachment: scroll; /* o simplemente eliminar el patrón */ background-image: none; } } z-index y el contenido que desaparece detrás del fondo # El problema: Añades un fondo decorativo y de repente el texto o los elementos interactivos desaparecen o quedan inaccesibles.\nPor qué ocurre: El fondo tiene un z-index mayor que el contenido, o el contenido no tiene position definido (necesario para que z-index funcione).\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; } Especificidad: cuando tu CSS no sobreescribe al del framework # El problema: Escribes una regla CSS pero no tiene efecto. El inspector muestra que está siendo sobreescrita por el framework (Tailwind, Bootstrap, etc.).\nPor qué ocurre: La especificidad CSS determina qué regla gana. Un selector de clase (.dark body) tiene menos peso que un selector compuesto del framework.\nSoluciones por orden de preferencia:\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; } Evita !important siempre que puedas — crea una deuda de especificidad que se acumula y hace el CSS imposible de mantener.\nVariables CSS: el orden importa # El problema: Defines variables CSS (custom properties) pero en algunos contextos no funcionan.\nPor qué ocurre: Las variables CSS (custom properties) solo son accesibles en el elemento donde se definen y sus descendientes. Si las defines en body, no estarán disponibles en 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); } Depuración rápida con el inspector # Cuando algo no funciona:\nAbre DevTools (F12) y selecciona el elemento problemático En la pestaña Styles, busca propiedades tachadas — están siendo sobreescritas Filtra por :hov para ver estilos de estados (:hover, :focus) Activa/desactiva el modo oscuro desde DevTools: panel Rendering → Emulate CSS media feature prefers-color-scheme Usa la pestaña Computed para ver el valor final que aplica realmente La mayoría de estos errores tienen la misma raíz: asumir que el estilo se propaga hacia arriba en el DOM cuando en realidad solo baja. html y body son el punto de partida — asegúrate de que tienen exactamente el aspecto que quieres antes de preocuparte por el resto.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/css-errores-comunes-estilos/","section":"Posts","summary":"CSS parece sencillo hasta que algo no funciona como esperas. Llevas horas mirando el inspector del navegador, cambias una propiedad, otra, y el problema sigue. Este artículo recoge los errores más frecuentes que me he encontrado montando este blog, con soluciones directas.\nEl fondo que desaparece al hacer scroll # El problema: La página se ve bien en la parte visible, pero al bajar aparece un fondo blanco o distinto al esperado.\n","title":"CSS que no falla: errores comunes al estilizar páginas web y cómo evitarlos","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/dark-mode/","section":"Tags","summary":"","title":"Dark-Mode","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/categories/desarrollo-web/","section":"Categories","summary":"","title":"Desarrollo Web","type":"categories"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/categories/dise%C3%B1o/","section":"Categories","summary":"","title":"Diseño","type":"categories"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/dise%C3%B1o/","section":"Tags","summary":"","title":"Diseño","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/email/","section":"Tags","summary":"","title":"Email","type":"tags"},{"content":" El problema: ataques por fuerza bruta # Después de exponer mi servidor Ubuntu a internet, me pasé una noche revisando logs. SSH recibía intentos de login fallidos cada segundo. Nginx también tenía solicitudes sospechosas a rutas comunes. Necesitaba algo que bloqueara estos intentos automáticamente. Fail2ban fue mi solución.\nInstalación # sudo apt update sudo apt install fail2ban sudo systemctl start fail2ban sudo systemctl enable fail2ban Verificar que está corriendo:\nsudo systemctl status fail2ban Estructura de Fail2ban # Fail2ban funciona así: monitorea logs, detecta patrones de fallo y crea reglas de firewall para bloquear IPs. Tiene tres componentes clave:\nJails: definen qué servicio proteger Filters: patrones regex para detectar intentos fallidos Actions: qué hacer cuando se detecta un ataque (ban, email, etc) Configuración de SSH # El jail de SSH viene preconfigurado, pero lo personalicé. Crear archivo /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 Explicación:\nbantime: segundos que dura el bloqueo (1 hora) findtime: ventana en segundos para contar intentos (10 min) maxretry: intentos fallidos antes de banear (3 en SSH, más restrictivo) Configuración de Nginx # Para Nginx necesité crear un jail personalizado. Primero, el filtro en /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 = Luego, agregar el jail al archivo local:\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 Aplicar cambios # sudo systemctl restart fail2ban Verificar que los jails están activos:\nsudo fail2ban-client status Ver estado detallado de SSH:\nsudo fail2ban-client status sshd Monitoreo # Después de algunas horas, revisé la actividad:\nsudo fail2ban-client status sshd La salida muestra IPs baneadas. Para ver detalles del jail:\nsudo tail -f /var/log/fail2ban.log Desbloquear una IP (por si acaso) # Si me equivoqué y bloqueé mi propia IP:\nsudo fail2ban-client set sshd unbanip \u0026lt;IP\u0026gt; Notas importantes # Fail2ban no reemplaza SSH keys. Seguí usando autenticación por clave, no contraseña. Aumenté maxretry en SSH a 3 porque es más restrictivo que 5 en aplicaciones web. Los logs de Nginx deben estar en formato combinado. Verificar /etc/nginx/nginx.conf. Para cambios en filtros, reiniciar: sudo systemctl restart fail2ban. Resultado # Después de implementar esto, los intentos de fuerza bruta desaparecieron. Los logs dejaron de ser un caos. El servidor se siente más tranquilo.\nFail2ban no es una bala de plata, pero es un escudo efectivo contra automatización básica. Vale la pena el tiempo invertido en configurarlo correctamente.\nEquipamiento recomendado # YubiKey 5 NFC — Llave de seguridad física para 2FA y acceso SSH seguro Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/fail2ban-para-proteger-ssh-y-nginx-configuracion-practica-en-ubuntu/","section":"Posts","summary":"El problema: ataques por fuerza bruta # Después de exponer mi servidor Ubuntu a internet, me pasé una noche revisando logs. SSH recibía intentos de login fallidos cada segundo. Nginx también tenía solicitudes sospechosas a rutas comunes. Necesitaba algo que bloqueara estos intentos automáticamente. Fail2ban fue mi solución.\nInstalación # sudo apt update sudo apt install fail2ban sudo systemctl start fail2ban sudo systemctl enable fail2ban Verificar que está corriendo:\n","title":"Fail2ban para proteger SSH y Nginx: configuración práctica en Ubuntu","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/failover/","section":"Tags","summary":"","title":"Failover","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/frontend/","section":"Tags","summary":"","title":"Frontend","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/gitlab/","section":"Tags","summary":"","title":"Gitlab","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/gmail/","section":"Tags","summary":"","title":"Gmail","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/google-search-console/","section":"Tags","summary":"","title":"Google-Search-Console","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/https/","section":"Tags","summary":"","title":"Https","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/indexaci%C3%B3n/","section":"Tags","summary":"","title":"Indexación","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/lets-encrypt/","section":"Tags","summary":"","title":"Let's-Encrypt","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/loki/","section":"Tags","summary":"","title":"Loki","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/categories/monitoring/","section":"Categories","summary":"","title":"Monitoring","type":"categories"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/monitoring/","section":"Tags","summary":"","title":"Monitoring","type":"tags"},{"content":"Un servidor sin monitorización es un servidor ciego. No sabes cuándo se llena el disco, qué contenedor está consumiendo demasiada RAM, o cuántas peticiones 404 está generando tu web. Este artículo documenta cómo configuré el stack completo: Prometheus + Node Exporter + Grafana + Loki + Promtail.\nLa arquitectura # [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 Todos los servicios corren en Docker, coordinados por el mismo docker-compose.yml.\nMétricas del sistema: Node Exporter # Node Exporter expone métricas del hardware y del SO. El truco: tiene que correr con network_mode: host para ver las interfaces de red reales del servidor. Si corre en red de Docker, solo ve la interfaz eth0 del contenedor.\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 Escucha en 127.0.0.1:9100. Prometheus lo alcanza por 172.17.0.1:9100 (la IP del host desde la red Docker).\nMétricas de contenedores: docker stats + textfile collector # El problema con cAdvisor es que no funciona con Docker 29 y el driver de almacenamiento overlayfs en cgroupv2 — falla con \u0026ldquo;failed to identify read-write layer ID\u0026rdquo;.\nLa solución: un contenedor ligero que ejecuta docker stats cada 30 segundos y escribe el resultado en formato Prometheus en un archivo que Node Exporter lee.\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; La escritura atómica (tmp → final) evita que Prometheus lea un archivo a medias.\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: recolectar y retener # 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 Configuración de scraping:\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 es la IP del host accesible desde la red Docker bridge. Los datos se retienen 30 días.\nLogs: Loki + Promtail # Loki almacena logs sin indexar el contenido completo — solo las etiquetas (labels). Promtail los recoge y los envía con etiquetas como 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 Necesita correr como root para leer /var/log.\nGrafana: dashboards # Grafana se conecta a Prometheus y Loki como data sources. Los dashboards más útiles:\nSistema (Node Exporter):\nCPU total y por núcleo RAM usada / libre / cache Disco: uso por partición, IOPS, throughput Red: tráfico de entrada/salida por interfaz Contenedores (docker stats):\nCPU % por contenedor RAM por contenedor vs límite Estado (running/stopped) Tráfico de red por contenedor Logs (Loki):\nLogs de Nginx en tiempo real Peticiones por código de estado (200, 301, 404, 500) Top de IPs con más peticiones Top de rutas más accedidas Problema: [$__range] en consultas instantáneas de Loki # Al usar paneles de tipo \u0026ldquo;stat\u0026rdquo; o \u0026ldquo;piechart\u0026rdquo; con Loki, la variable [$__range] no se resuelve — Grafana devuelve \u0026ldquo;empty duration string\u0026rdquo;. La solución es usar una duración fija:\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])) Los paneles de tipo \u0026ldquo;time series\u0026rdquo; sí admiten [$__interval] correctamente.\nSeguridad del stack # Prometheus y Loki no tienen acceso externo — solo en la red interna monitoring Grafana es el único punto de acceso, protegido con Traefik y Let\u0026rsquo;s Encrypt GF_AUTH_ANONYMOUS_ENABLED=false y GF_USERS_ALLOW_SIGN_UP=false en Grafana Node Exporter escucha solo en 127.0.0.1, no expuesto en todas las interfaces Resultado # Con este stack tienes visibilidad completa del servidor: qué procesos consumen recursos, qué contenedores fallan, qué peticiones recibe tu web y qué errores genera. Todo en dashboards accesibles desde monitor.serviciosrogeliowar.com.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/monitoring-prometheus-grafana-loki/","section":"Posts","summary":"Un servidor sin monitorización es un servidor ciego. No sabes cuándo se llena el disco, qué contenedor está consumiendo demasiada RAM, o cuántas peticiones 404 está generando tu web. Este artículo documenta cómo configuré el stack completo: Prometheus + Node Exporter + Grafana + Loki + Promtail.\nLa arquitectura # [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 Todos los servicios corren en Docker, coordinados por el mismo docker-compose.yml.\n","title":"Monitorización completa con Prometheus, Grafana y Loki: métricas, logs y contenedores Docker","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/msmtp/","section":"Tags","summary":"","title":"Msmtp","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/node-exporter/","section":"Tags","summary":"","title":"Node-Exporter","type":"tags"},{"content":" Por qué necesitas esto # Cuando corres un servidor en casa, necesitas saber si algo raro sucede. Un script que te envíe un email cuando detecta un intento de acceso fallido, un certificado por expirar o un disco casi lleno es invaluable. El problema es que tu ISP bloquea el puerto 25, así que no puedes usar sendmail directamente. Aquí entra msmtp.\nmsmtp es un cliente SMTP minimalista. No es un servidor de correo completo, solo envía emails a través de servidores externos como Gmail. Perfecto para casos como el nuestro.\nInstalación # En Debian/Ubuntu:\nsudo apt-get update sudo apt-get install msmtp msmtp-mta La opción msmtp-mta es importante porque crea un enlace simbólico que hace que otros programas piensen que estás usando sendmail tradicional.\nConfiguración básica con Gmail # Gmail tiene dos opciones: contraseña de aplicación o usar el protocolo SMTP directo. Voy a usar contraseña de aplicación porque es más seguro y funciona sin habilitar \u0026ldquo;aplicaciones menos seguras\u0026rdquo;.\nPrimero, crea una contraseña de aplicación en tu cuenta Google:\nVe a myaccount.google.com Seguridad → Contraseñas de aplicaciones (necesitas 2FA habilitado) Selecciona \u0026ldquo;Correo\u0026rdquo; y \u0026ldquo;Otros (personalizado)\u0026rdquo; Gmail te genera una contraseña de 16 caracteres Ahora crea o edita ~/.msmtprc:\nnano ~/.msmtprc Agrega esto:\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 Permisos críticos:\nchmod 600 ~/.msmtprc Esto es importante. Si otros usuarios pueden leer el archivo, ven tu contraseña.\nTest inicial # Prueba que funciona:\necho \u0026#34;Cuerpo del email\u0026#34; | msmtp tu-email@gmail.com -S from=tu-email@gmail.com Revisa tu bandeja de entrada. Si recibes el email, está funcionando.\nUsar msmtp desde scripts de seguridad # Ahora integra esto en tus alertas. Aquí un ejemplo simple que monitorea intentos SSH fallidos:\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 Guárdalo en /usr/local/bin/check-ssh-alerts.sh y hazlo ejecutable:\nsudo chmod +x /usr/local/bin/check-ssh-alerts.sh Automatizar con cron # Agrega a crontab para que se ejecute cada hora:\nsudo crontab -e 0 * * * * /usr/local/bin/check-ssh-alerts.sh Problemas comunes # \u0026ldquo;SMTP Error: 535\u0026rdquo; → Contraseña incorrecta. Verifica que usaste la contraseña de aplicación, no tu contraseña de Google normal.\n\u0026ldquo;TLS connection refused\u0026rdquo; → Revisa que el certificado esté en el path correcto. Usa ls /etc/ssl/certs/ca-certificates.crt.\nEmails no llegan → Revisa el log: cat ~/.msmtp.log. Gmail a veces rechaza si detecta actividad sospechosa.\nSeguridad adicional # Si el servidor corre con usuario regular pero los scripts necesitan ejecutarse como root, considera:\nsudo visudo Y agrega:\nnobody ALL=(ALL) NOPASSWD: /usr/local/bin/check-ssh-alerts.sh Así corres el script sin pedir contraseña en cron.\nConclusión # Con msmtp tienes alertas de seguridad automáticas en minutos, sin complicaciones de montar un servidor SMTP completo. Lo uso en mi servidor doméstico para monitorear cambios en iptables, certificados vencidos y picos de carga. Durmiendo tranquilo sabiendo que algo me avisa si hay problema.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Mini PC Intel N100 — Mini PC silencioso y eficiente para servidor doméstico 24/7 Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/notificaciones-de-seguridad-por-email-desde-el-terminal-con-msmtp-y-gmail/","section":"Posts","summary":"Por qué necesitas esto # Cuando corres un servidor en casa, necesitas saber si algo raro sucede. Un script que te envíe un email cuando detecta un intento de acceso fallido, un certificado por expirar o un disco casi lleno es invaluable. El problema es que tu ISP bloquea el puerto 25, así que no puedes usar sendmail directamente. Aquí entra msmtp.\n","title":"Notificaciones de seguridad por email desde el terminal con msmtp y Gmail","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/red/","section":"Tags","summary":"","title":"Red","type":"tags"},{"content":"Tener un servidor en casa tiene un punto débil evidente: si se va la luz, el router falla, o el disco muere, tu web desaparece. La solución es tener una réplica en la nube lista para activarse en minutos.\nLa arquitectura # [servidor-casa] → rsync cada 6h → [VPS réplica] servicios activos réplica en espera TTL DNS: 5 min Uptime Kuma vigilando El servidor de casa empuja contenido al VPS cada 6 horas. Si el servidor cae, cambio el DNS y en 5 minutos el VPS sirve el sitio.\n¿Por qué push y no pull? # El servidor de casa está detrás de un router doméstico (Digi). El router solo tiene abiertos los puertos 80 y 443. El VPS no puede conectarse por SSH al servidor de casa directamente.\nLa solución: el servidor de casa empuja al VPS (tiene SSH saliente libre), el VPS solo recibe.\nPreparación del VPS # El VPS (Debian 12, 2 CPUs, 4GB RAM, 30GB disco) ya tenía Docker instalado. Primero, seguridad:\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 como proxy inverso # Mismo Traefik v2.11 que en el servidor de casa, con Let\u0026rsquo;s Encrypt automático:\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: monitorización externa # Uptime Kuma vigila al servidor de casa desde fuera. Si no responde, aviso inmediato por email:\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 Réplicas en espera # Los servicios de web y blog están corriendo en el VPS pero con traefik.enable=false — Traefik los ignora, no son accesibles desde internet. Solo se activan en emergencia:\nweb-replica: image: nginx:alpine labels: - traefik.enable=false # ← cambiar a true en emergencia Script rsync desde el servidor de casa # #!/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 en el servidor de casa:\n0 */6 * * * ~/infra/sync-to-vps.sh Procedimiento de conmutación en emergencia # Cuando el servidor de casa cae:\nEditar en el VPS: cambiar traefik.enable=false a traefik.enable=true en web y blog docker compose up -d en cada directorio En el panel DNS, cambiar la A record de tu dominio de la IP del servidor de casa a la IP del VPS Con TTL de 5 minutos, en menos de 10 minutos el sitio vuelve Cuando el servidor de casa se recupera, proceso inverso: restaurar DNS, volver a traefik.enable=false en el VPS.\nResultado # Uptime Kuma vigilando desde fuera en uptime.serviciosrogeliowar.com rsync automático cada 6 horas — máximo 6 horas de contenido perdido en un fallo Tiempo de recuperación (RTO): ~5 minutos Pérdida máxima de datos (RPO): ~6 horas Coste adicional: solo el VPS (ya lo tenía) Para un servidor doméstico, esta arquitectura es más que suficiente.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/replica-emergencia-vps/","section":"Posts","summary":"Tener un servidor en casa tiene un punto débil evidente: si se va la luz, el router falla, o el disco muere, tu web desaparece. La solución es tener una réplica en la nube lista para activarse en minutos.\nLa arquitectura # [servidor-casa] → rsync cada 6h → [VPS réplica] servicios activos réplica en espera TTL DNS: 5 min Uptime Kuma vigilando El servidor de casa empuja contenido al VPS cada 6 horas. Si el servidor cae, cambio el DNS y en 5 minutos el VPS sirve el sitio.\n","title":"Réplica de emergencia: cómo tener tu servidor de casa respaldado en un VPS","type":"posts"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/reverse-proxy/","section":"Tags","summary":"","title":"Reverse-Proxy","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/seo/","section":"Tags","summary":"","title":"Seo","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/sitemap/","section":"Tags","summary":"","title":"Sitemap","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/sitios-est%C3%A1ticos/","section":"Tags","summary":"","title":"Sitios Estáticos","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/terminal/","section":"Tags","summary":"","title":"Terminal","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/traefik/","section":"Tags","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/ubuntu/","section":"Tags","summary":"","title":"Ubuntu","type":"tags"},{"content":"","date":"30 de abril de 2026","externalUrl":null,"permalink":"/tags/web/","section":"Tags","summary":"","title":"Web","type":"tags"},{"content":"Uno de los problemas clásicos de tener un servidor en casa es el acceso remoto seguro. Abrir puertos SSH directamente al mundo es una mala idea — lo ves en los logs de auth: cientos de intentos al día. La solución elegante es una VPN, y WireGuard es hoy la mejor opción disponible.\n¿Por qué WireGuard? # Comparado con OpenVPN o IPSec:\nMucho más rápido — está integrado en el kernel de Linux desde la versión 5.6 Configuración mínima — el config del servidor cabe en 10 líneas Criptografía moderna — ChaCha20, Curve25519, BLAKE2 (ver protocolo WireGuard) Un solo puerto UDP — fácil de abrir en el router Arquitectura # [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 El servidor actúa como concentrador VPN. Cuando me conecto, obtengo la IP 10.10.0.2 y puedo acceder a cualquier servicio de la red local como si estuviera en casa.\nInstalación en Ubuntu # sudo apt-get install -y wireguard WireGuard viene en los repos de Ubuntu desde 20.04. En versiones más nuevas el módulo del kernel está incluido de serie.\nGeneración de claves # WireGuard usa criptografía de clave pública. Generamos un par para el servidor y otro para cada cliente:\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 Importante: las claves privadas no salen nunca del dispositivo que las genera. Solo se intercambian las claves públicas.\nConfiguración del servidor # Archivo /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 Las reglas de iptables en PostUp/PostDown habilitan el reenvío de paquetes (NAT) para que el cliente pueda llegar a la red local, no solo al servidor.\nPermisos estrictos al archivo:\nsudo chmod 600 /etc/wireguard/wg0.conf Habilitar IP forwarding # Sin esto, el servidor no reenvía paquetes entre 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 Arrancar WireGuard # sudo systemctl enable --now wg-quick@wg0 Verificar que está activo:\nsudo wg show wg0 Debe mostrar la interfaz escuchando en el puerto 51820 y el peer registrado.\nFirewall (UFW) # sudo ufw allow 51820/udp sudo ufw reload Configuración del cliente # Archivo wg-casa.conf para el portátil o móvil:\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 significa que solo el tráfico hacia la red VPN va por el túnel — el resto de internet sigue saliendo directo. Si quisieras enrutar todo el tráfico (incluyendo navegación web) por casa, usarías 0.0.0.0/0.\nPersistentKeepalive = 25 mantiene la conexión viva aunque no haya tráfico — útil en redes móviles que cierran conexiones UDP inactivas.\nRouter: port forwarding # En el router hay que redirigir el puerto 51820 UDP a la IP local del servidor (p.ej. 192.168.1.X). Suele estar en Configuración → NAT → Redirección de puertos.\nQR para el móvil # En lugar de teclear el config en el móvil, generamos un QR:\nsudo apt-get install -y qrencode qrencode -t ansiutf8 \u0026lt; cliente.conf La app WireGuard (iOS/Android) lo escanea directamente.\nVerificación # Para probar que funciona de verdad, hay que conectarse desde una red diferente a la de casa — por ejemplo, datos móviles:\nDesactiva el wifi del móvil Activa el túnel WireGuard en la app Accede a un servicio del servidor por IP local (ej. http://192.168.1.X) Si responde, el túnel está funcionando correctamente.\nSeguridad adicional # Las claves privadas nunca viajan por la red — solo se intercambian públicas Sin usuarios ni contraseñas — autenticación puramente criptográfica Un peer = una clave pública — si pierdes un dispositivo, eliminas su [Peer] del servidor y ya no tiene acceso Fail2ban no aplica — WireGuard descarta silenciosamente paquetes inválidos sin responder Añadir más clientes # Para cada nuevo dispositivo, generamos un nuevo par de claves y añadimos un bloque [Peer] adicional en el servidor con su clave pública y una IP diferente (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 No hace falta reiniciar el servicio — WireGuard añade peers en caliente.\nModo túnel completo: todo el tráfico por la VPN # Por defecto, el cliente solo enruta el tráfico de la red local (10.10.0.0/24) por el túnel. Si quieres que toda la navegación del dispositivo pase por tu servidor — útil en redes públicas, hoteles o WiFi desconocido — cambia AllowedIPs en el cliente:\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 Con 0.0.0.0/0 toda la navegación sale por tu IP doméstica. Ventajas: privacidad en redes públicas, tu IP real en todo momento. Inconveniente: tu velocidad de subida en casa limita la navegación del dispositivo remoto.\nEn la app WireGuard (móvil o escritorio) puedes cambiar esto editando el túnel sin necesidad de tocar el servidor.\nCon esto tengo acceso completo a mi red doméstica desde cualquier lugar, sin exponer ningún puerto adicional al mundo y con criptografía moderna. El siguiente paso natural es montar backups automáticos desde el VPS hacia el servidor de casa, usando este túnel como canal seguro.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"30 de abril de 2026","externalUrl":null,"permalink":"/posts/wireguard-vpn-servidor-dom%C3%A9stico/","section":"Posts","summary":"Uno de los problemas clásicos de tener un servidor en casa es el acceso remoto seguro. Abrir puertos SSH directamente al mundo es una mala idea — lo ves en los logs de auth: cientos de intentos al día. La solución elegante es una VPN, y WireGuard es hoy la mejor opción disponible.\n¿Por qué WireGuard? # Comparado con OpenVPN o IPSec:\n","title":"WireGuard VPN: accede a tu servidor doméstico desde cualquier lugar","type":"posts"},{"content":"Este blog nació de un proceso real.\nTenía un dominio, un servidor en casa y ganas de montar algo propio. En lugar de seguir un tutorial genérico, decidí documentar exactamente lo que iba haciendo — errores incluidos.\nQué encontrarás aquí # Artículos sobre lo que voy construyendo y aprendiendo:\nConfiguración de servidores Linux Despliegue de servicios con Docker Redes, DNS y seguridad Automatización e infraestructura Sin atajos, sin simplificaciones. Proceso real, documentado paso a paso.\nEquipamiento recomendado # Raspberry Pi 3 B+ — Servidor ligero de bajo consumo para empezar tu homelab Raspberry Pi 4 (4GB) — La base perfecta para homelab, Docker y monitorización Enlaces de afiliado. Sin coste extra para ti.\n","date":"29 de abril de 2026","externalUrl":null,"permalink":"/posts/bienvenida/","section":"Posts","summary":"Este blog nació de un proceso real.\nTenía un dominio, un servidor en casa y ganas de montar algo propio. En lugar de seguir un tutorial genérico, decidí documentar exactamente lo que iba haciendo — errores incluidos.\nQué encontrarás aquí # Artículos sobre lo que voy construyendo y aprendiendo:\nConfiguración de servidores Linux Despliegue de servicios con Docker Redes, DNS y seguridad Automatización e infraestructura Sin atajos, sin simplificaciones. Proceso real, documentado paso a paso.\n","title":"Cómo nació este blog","type":"posts"},{"content":"","date":"29 de abril de 2026","externalUrl":null,"permalink":"/tags/inicio/","section":"Tags","summary":"","title":"Inicio","type":"tags"},{"content":" Hola, soy Rogelio # Soy sysadmin con pasión por la infraestructura real: la que corre en hierro de verdad, no solo en la nube. Llevo años construyendo y manteniendo mi propia infraestructura web desde casa, aprendiendo en el proceso todo lo que no enseñan los cursos.\nQué encontrarás aquí # Este blog es mi cuaderno técnico público. Documento lo que hago, lo que rompo y cómo lo arreglo. Nada de ejemplos genéricos — todo viene de casos reales.\nLos temas principales:\nInfraestructura Docker — compose, redes, volúmenes, reverse proxy con Traefik Monitorización — Prometheus, Grafana, Loki, alertas reales Redes — WireGuard, fail2ban, hardening SSH, segmentación CI/CD — GitLab Runner, Hugo, despliegues automáticos Automatización — scripts Python, crons, agentes IA Mi stack actual # Servidor principal: máquina doméstica con Linux Réplica: VPS en Clouding con failover automático DNS Servicios: Traefik, Grafana, Loki, Prometheus, Listmonk, Hugo Tunnel privado: WireGuard entre ambos servidores Contacto # Puedes encontrarme en:\nLinkedIn: linkedin.com/in/rogeliowar GitLab: gitlab.com/rogeliowar Instagram: @rogeliowarr Email: serviciosrogeliowar@gmail.com ","date":"1 de enero de 2026","externalUrl":null,"permalink":"/sobre-mi/","section":"Servicios Rogeliowar","summary":"Hola, soy Rogelio # Soy sysadmin con pasión por la infraestructura real: la que corre en hierro de verdad, no solo en la nube. Llevo años construyendo y manteniendo mi propia infraestructura web desde casa, aprendiendo en el proceso todo lo que no enseñan los cursos.\nQué encontrarás aquí # Este blog es mi cuaderno técnico público. Documento lo que hago, lo que rompo y cómo lo arreglo. Nada de ejemplos genéricos — todo viene de casos reales.\n","title":"Sobre mí","type":"page"},{"content":"","externalUrl":null,"permalink":"/viajes/","section":"Viajes \u0026 Experiencias","summary":"","title":"Viajes \u0026 Experiencias","type":"viajes"}]