La regla 3-2-1 es el estándar de la industria para backups confiables:
3 copias del dato
→ el original + 2 backups
2 tipos de soporte distintos
→ ej: disco del VPS + almacenamiento en la nube
1 copia offsite (fuera del servidor principal)
→ si el datacenter se cae, tus datos sobreviven
Un backup que solo existe en el mismo VPS no es un backup real — si el servidor muere, todo se va junto.
CRÍTICO — perder esto es catastrófico:
✓ Bases de datos (MariaDB, PostgreSQL, SQLite)
✓ Archivos subidos por usuarios (/var/www/*/uploads)
✓ Variables de entorno y secretos (.env, secrets)
✓ Certificados SSL personalizados (no Let's Encrypt*)
IMPORTANTE — se puede reconstruir pero lleva tiempo:
✓ Código de la aplicación (si no está en Git)
✓ Configuraciones de Nginx (/etc/nginx/)
✓ Configuraciones de servicios (/etc/*)
✓ Crontabs (crontab -l)
✓ Volúmenes de Docker
PRESCINDIBLE — fácil de reinstalar:
✗ Paquetes del sistema (apt)
✗ node_modules, vendor, venv
✗ Caché, logs temporales
✗ Certificados de Let's Encrypt*
* Let's Encrypt se puede regenerar gratis en minutos con Certbot — no necesitás backupearlo. Sí hacer backup de configuraciones personalizadas de SSL.
DIARIO → bases de datos (son lo que más cambia)
→ archivos subidos por usuarios
→ retener por 7 días
SEMANAL → configuraciones del sistema
→ código si no usás Git
→ retener por 4 semanas
MENSUAL → snapshot completo del VPS (si el proveedor lo permite)
→ retener por 3-6 meses
ANTES DE → cualquier cambio grande en producción
CAMBIOS (deploy importante, migraciones, actualizaciones)
En Donweb VPS revisá si incluyen snapshots automáticos en el panel — pueden ser un complemento útil a tus backups propios.
# Exportar una base de datos mysqldump -u root -p mi_base > mi_base.sql # Con fecha en el nombre mysqldump -u root -p mi_base > mi_base_$(date +%Y%m%d).sql # Comprimido (ocupa hasta 10x menos) mysqldump -u root -p mi_base | gzip > mi_base_$(date +%Y%m%d).sql.gz # Todas las bases de datos mysqldump -u root -p --all-databases | gzip > all_dbs_$(date +%Y%m%d).sql.gz # Con opciones recomendadas para producción mysqldump -u root -p \ --single-transaction \ --routines \ --triggers \ --events \ --hex-blob \ mi_base | gzip > mi_base_$(date +%Y%m%d_%H%M).sql.gz
--single-transaction hace el dump sin bloquear las tablas — esencial en producción con InnoDB para no interrumpir la app durante el backup.
Script que hace backup de cada base por separado, evitando las bases del sistema. Guardar en /usr/local/bin/backup-dbs.sh
#!/bin/bash # backup-dbs.sh — backup de todas las bases de datos BACKUP_DIR="/var/backups/mysql" RETENTION_DAYS=7 DATE="$(date +%Y%m%d_%H%M)" MYSQL_USER="root" MYSQL_PASS="$(cat /etc/mysql/backup.pass)" # contraseña en archivo # Crear directorio si no existe mkdir -p "$BACKUP_DIR" # Listar bases (excluir las del sistema) DATABASES=$(mysql -u"$MYSQL_USER" -p"$MYSQL_PASS" -e \ "SHOW DATABASES;" 2>/dev/null | grep -Ev \ "(Database|information_schema|performance_schema|mysql|sys)") # Backup de cada base for DB in $DATABASES; do echo "→ Backupeando: $DB" mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASS" \ --single-transaction --routines --triggers \ "$DB" | gzip > "$BACKUP_DIR/${DB}_${DATE}.sql.gz" if [ ${PIPESTATUS[0]} -eq 0 ]; then echo " ✓ $DB → ${DB}_${DATE}.sql.gz" else echo " ✗ ERROR en $DB" >&2 fi done # Eliminar backups más viejos que RETENTION_DAYS find "$BACKUP_DIR" -name "*.sql.gz" \ -mtime +"$RETENTION_DAYS" -delete echo "Limpieza: eliminados backups de más de $RETENTION_DAYS días"
# Guardar la contraseña de MySQL en archivo con permisos restringidos echo "tu_password_aqui" > /etc/mysql/backup.pass chmod 600 /etc/mysql/backup.pass # Dar permisos de ejecución al script chmod +x /usr/local/bin/backup-dbs.sh # Probar /usr/local/bin/backup-dbs.sh
Para bases de datos grandes, mariabackup hace una copia física sin bloquear nada y soporta backups incrementales.
# Instalar apt install mariadb-backup # Backup completo mariabackup --backup \ --target-dir=/var/backups/mariadb/full \ --user=root --password=tu_password # Preparar el backup (necesario antes de restaurar) mariabackup --prepare \ --target-dir=/var/backups/mariadb/full # Backup incremental (solo los cambios desde el último full) mariabackup --backup \ --target-dir=/var/backups/mariadb/inc1 \ --incremental-basedir=/var/backups/mariadb/full \ --user=root --password=tu_password
mariabackup es ideal cuando la base supera varios GB y el tiempo de mysqldump se vuelve un problema.
# Comprimir directorio completo tar czf backup_www_$(date +%Y%m%d).tar.gz /var/www/ # Excluir carpetas innecesarias tar czf backup_www_$(date +%Y%m%d).tar.gz \ --exclude=/var/www/*/node_modules \ --exclude=/var/www/*/vendor \ --exclude=/var/www/*/.git \ --exclude=/var/www/*/storage/logs \ /var/www/ # Backup de configuraciones del sistema tar czf backup_etc_$(date +%Y%m%d).tar.gz \ /etc/nginx/ \ /etc/mysql/ \ /etc/php/ \ /etc/fail2ban/ \ /etc/ufw/ \ /etc/crontab \ /var/spool/cron/ # Ver contenido de un tar sin extraer tar tzf backup_www_20240101.tar.gz | head -30 # Extraer en directorio específico tar xzf backup_www_20240101.tar.gz -C /tmp/restore/ # Opciones de tar: # c = crear x = extraer t = listar # z = gzip j = bzip2 J = xz # f = archivo v = verbose
rsync es mucho más eficiente que tar para backups frecuentes — solo transfiere lo que cambió.
# Sincronizar directorio local rsync -av /var/www/ /var/backups/www/ # Con opciones recomendadas para backup rsync -avz \ --delete \ --exclude='node_modules/' \ --exclude='.git/' \ --exclude='*.log' \ /var/www/ /var/backups/www/ # Hacia servidor remoto via SSH rsync -avz -e "ssh -p 2222 -i ~/.ssh/backup_key" \ /var/www/ \ backup_user@servidor-remoto.com:/backups/www/ # Desde servidor remoto hacia local (pull) rsync -avz -e "ssh -p 2222" \ backup_user@vps:/var/www/ \ /backups/vps/www/ # Dry run — simular sin hacer cambios rsync -avzn /var/www/ /var/backups/www/ # Opciones útiles: # -a = archive (permisos, timestamps, links, recursivo) # -v = verbose # -z = comprimir en tránsito # -n = dry run # --delete = eliminar en destino lo que no está en origen # --progress = mostrar progreso
Técnica que crea múltiples snapshots del tipo "Time Machine" — cada día parece un backup completo pero solo ocupa el espacio de los cambios, gracias a hardlinks.
#!/bin/bash # backup-rotate.sh — 7 snapshots diarios sin duplicar datos SRC="/var/www/" DEST="/var/backups/snapshots" DATE="$(date +%Y%m%d)" # Si existe un backup de ayer, usarlo como base de hardlinks LINK_DEST="" YESTERDAY="$(ls -d $DEST/20* 2>/dev/null | tail -1)" if [ -d "$YESTERDAY" ]; then LINK_DEST="--link-dest=$YESTERDAY" fi # Crear snapshot de hoy rsync -az --delete \ $LINK_DEST \ --exclude='node_modules/' \ --exclude='.git/' \ "$SRC" "$DEST/$DATE/" # Mantener solo los últimos 7 snapshots ls -d $DEST/20* | head -n -7 | xargs rm -rf echo "Snapshot $DATE completado"
Con hardlinks, 7 días de backups de un directorio de 5GB ocupan solo 5GB más los cambios incrementales — no 35GB.
AWS S3, Cloudflare R2, Backblaze B2 y similares son ideales como destino offsite. Se usan con la herramienta rclone o el CLI de AWS.
# Instalar rclone (soporta S3, R2, B2, GDrive, y más) curl https://rclone.org/install.sh | sudo bash # Configurar destino (interactivo) rclone config # → Seguir los pasos para S3/R2/B2 # → El perfil queda guardado en ~/.config/rclone/rclone.conf # Subir backup al bucket rclone copy /var/backups/mysql/ remote:mi-bucket/mysql/ # Sincronizar (elimina en destino lo que no está en origen) rclone sync /var/backups/ remote:mi-bucket/vps-backups/ # Con progreso y log rclone copy /var/backups/mysql/ remote:mi-bucket/mysql/ \ --progress \ --log-file=/var/log/rclone-backup.log # Listar contenido del bucket rclone ls remote:mi-bucket/mysql/ # Verificar integridad (checksum) rclone check /var/backups/mysql/ remote:mi-bucket/mysql/
Cloudflare R2 tiene egress gratuito (no cobra por descargar los backups). Backblaze B2 tiene 10GB gratis. Ambas son alternativas más baratas que S3 para backups.
# Clave SSH dedicada para backups (sin passphrase para automatizar) ssh-keygen -t ed25519 -f ~/.ssh/id_backup -N "" -C "backup-key" ssh-copy-id -i ~/.ssh/id_backup.pub backup@servidor-remoto.com # Copiar backup al servidor remoto scp -i ~/.ssh/id_backup \ /var/backups/mysql/mi_base_$(date +%Y%m%d).sql.gz \ backup@servidor-remoto.com:/backups/mysql/ # Rsync al servidor remoto (incremental) rsync -avz \ -e "ssh -i ~/.ssh/id_backup -p 22" \ /var/backups/ \ backup@servidor-remoto.com:/backups/vps/ # Crear usuario restringido en el servidor destino useradd -m -s /bin/rbash backup mkdir -p /backups chown backup:backup /backups
Usar una clave SSH dedicada sin passphrase solo para backups — con permisos mínimos en el servidor destino (solo escritura en la carpeta de backups).
Si los backups contienen datos sensibles y se envían a la nube, encriptarlos antes de subir.
# Encriptar con GPG (clave simétrica) gpg --symmetric \ --cipher-algo AES256 \ --batch \ --passphrase "frase_muy_segura" \ mi_base_20240101.sql.gz # Genera: mi_base_20240101.sql.gz.gpg # Desencriptar gpg --decrypt \ --batch \ --passphrase "frase_muy_segura" \ mi_base_20240101.sql.gz.gpg > mi_base_20240101.sql.gz # rclone puede encriptar automáticamente # Configurar un remote de tipo "crypt" sobre el bucket rclone config # → New remote → Type: crypt → Remote: mi-bucket # → rclone crypt encripta antes de subir, desencripta al bajar # Una vez configurado, usarlo igual que cualquier remote rclone copy /var/backups/ crypt-remote:backups/
Guardá la passphrase de encriptación en un lugar seguro FUERA del servidor. Si la perdés, los backups son irrecuperables.
Un script que lo hace todo: backup de bases de datos, archivos, y sube a la nube. Guardar en /usr/local/bin/backup-diario.sh
#!/bin/bash # backup-diario.sh — backup completo del VPS set -euo pipefail # salir si hay errores ## ── CONFIGURACIÓN ───────────────────────── BACKUP_DIR="/var/backups/diario" DATE="$(date +%Y%m%d_%H%M)" RETENTION=7 # días a retener LOG="/var/log/backup.log" EMAIL="tu@email.com" MYSQL_PASS="$(cat /etc/mysql/backup.pass)" REMOTE="r2:mi-bucket/vps" # rclone remote ## ── FUNCIONES ────────────────────────────── log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; } ok() { echo "[$(date '+%H:%M:%S')] ✓ $*" | tee -a "$LOG"; } err() { echo "[$(date '+%H:%M:%S')] ✗ $*" | tee -a "$LOG" >&2; } mkdir -p "$BACKUP_DIR/mysql" "$BACKUP_DIR/www" "$BACKUP_DIR/etc" log "=== Backup iniciado: $DATE ===" ## ── 1. BASES DE DATOS ────────────────────── log "Exportando bases de datos..." DATABASES=$(mysql -uroot -p"$MYSQL_PASS" -e "SHOW DATABASES;" 2>/dev/null \ | grep -Ev "(Database|information_schema|performance_schema|mysql|sys)") for DB in $DATABASES; do mysqldump -uroot -p"$MYSQL_PASS" \ --single-transaction --routines --triggers \ "$DB" 2>/dev/null \ | gzip > "$BACKUP_DIR/mysql/${DB}_${DATE}.sql.gz" \ && ok "$DB" || err "$DB falló" done ## ── 2. ARCHIVOS WEB ──────────────────────── log "Sincronizando /var/www..." rsync -az --delete \ --exclude='node_modules/' \ --exclude='vendor/' \ --exclude='.git/' \ --exclude='*.log' \ /var/www/ "$BACKUP_DIR/www/" \ && ok "www" || err "www falló" ## ── 3. CONFIGURACIONES ───────────────────── log "Backupeando configuraciones..." tar czf "$BACKUP_DIR/etc/etc_${DATE}.tar.gz" \ /etc/nginx/ /etc/mysql/ /etc/php/ \ /etc/fail2ban/ /etc/ufw/ \ 2>/dev/null \ && ok "etc" || err "etc falló" # Guardar crontabs crontab -l > "$BACKUP_DIR/etc/crontab_root_${DATE}.txt" 2>/dev/null || true ## ── 4. SUBIR A LA NUBE ───────────────────── log "Subiendo a $REMOTE..." rclone sync "$BACKUP_DIR/" "$REMOTE/" \ --log-file="$LOG" \ && ok "subida completada" || err "subida falló" ## ── 5. LIMPIEZA LOCAL ────────────────────── find "$BACKUP_DIR/mysql" -name "*.sql.gz" \ -mtime +"$RETENTION" -delete find "$BACKUP_DIR/etc" -name "*.tar.gz" \ -mtime +"$RETENTION" -delete log "Limpieza: eliminados archivos de más de $RETENTION días" ## ── 6. REPORTE ───────────────────────────── SIZE=$(du -sh "$BACKUP_DIR" | cut -f1) log "=== Backup completado. Tamaño: $SIZE ===" tail -20 "$LOG" | mail -s "✓ Backup VPS OK — $DATE" "$EMAIL"
chmod +x /usr/local/bin/backup-diario.sh
# Probar manualmente antes de automatizar
/usr/local/bin/backup-diario.sh
# Editar crontab de root crontab -e # Backup diario a las 2:00 AM 0 2 * * * /usr/local/bin/backup-diario.sh # Backup de base de datos cada 6 horas 0 */6 * * * /usr/local/bin/backup-dbs.sh # Backup de configuraciones los domingos a las 3 AM 0 3 * * 0 tar czf /var/backups/etc/etc_$(date +\%Y\%m\%d).tar.gz /etc/nginx/ /etc/mysql/ # Verificar que el backup se hizo (alertar si no hay archivo de hoy) 30 8 * * * /usr/local/bin/verificar-backups.sh
Las % en crontab necesitan escaparse como \%. O mejor: ponerlas dentro del script y llamar solo al script desde cron.
Los timers de systemd son más robustos que cron — tienen logs integrados, manejo de errores y pueden recuperar ejecuciones perdidas.
# /etc/systemd/system/backup.service [Unit] Description=Backup diario del VPS After=network.target mysql.service [Service] Type=oneshot ExecStart=/usr/local/bin/backup-diario.sh User=root StandardOutput=journal StandardError=journal
# /etc/systemd/system/backup.timer [Unit] Description=Timer de backup diario [Timer] OnCalendar=*-*-* 02:00:00 # todos los días a las 2 AM Persistent=true # ejecutar si el servidor estaba apagado RandomizedDelaySec=5min # variar un poco para no sobrecargar [Install] WantedBy=timers.target
systemctl daemon-reload
systemctl enable backup.timer
systemctl start backup.timer
# Ver próxima ejecución
systemctl list-timers backup.timer
# Ver logs del último backup
journalctl -u backup.service -n 50
Persistent=true es la gran ventaja sobre cron — si el servidor estaba apagado a las 2 AM, el backup se ejecuta la próxima vez que arranque.
# Restaurar desde dump sin comprimir mysql -u root -p mi_base < mi_base_20240101.sql # Restaurar desde dump comprimido gunzip < mi_base_20240101.sql.gz | mysql -u root -p mi_base zcat mi_base_20240101.sql.gz | mysql -u root -p mi_base # Si la base no existe, crearla primero mysql -u root -p -e "CREATE DATABASE mi_base CHARACTER SET utf8mb4;" gunzip < mi_base_20240101.sql.gz | mysql -u root -p mi_base # Restaurar solo una tabla específica del dump gunzip < mi_base_20240101.sql.gz \ | grep -A 1000 "CREATE TABLE \`usuarios\`" \ | grep -B 1000 "CREATE TABLE \`" \ | head -n -1 \ | mysql -u root -p mi_base # Restaurar con mariabackup systemctl stop mariadb mariabackup --copy-back \ --target-dir=/var/backups/mariadb/full chown -R mysql:mysql /var/lib/mysql systemctl start mariadb
# Ver contenido del backup antes de restaurar tar tzf backup_www_20240101.tar.gz | grep "mi-sitio" # Restaurar archivo o directorio específico del tar tar xzf backup_www_20240101.tar.gz \ -C / \ var/www/mi-sitio/uploads/foto.jpg # Restaurar directorio completo en ubicación alternativa tar xzf backup_www_20240101.tar.gz \ -C /tmp/restauracion/ # Comparar antes de sobreescribir diff -rq /tmp/restauracion/var/www/mi-sitio/ \ /var/www/mi-sitio/ # Restaurar desde snapshot de rsync rsync -av \ /var/backups/snapshots/20240101/mi-sitio/ \ /var/www/mi-sitio/ # Bajar backup desde la nube con rclone rclone copy r2:mi-bucket/vps/www/ /var/www/
# Extraer configuraciones del tar tar xzf backup_etc_20240101.tar.gz -C /tmp/etc-restore/ # Restaurar configuración de Nginx cp -r /tmp/etc-restore/etc/nginx/ /etc/nginx/ nginx -t && systemctl reload nginx # Restaurar crontab crontab /var/backups/etc/crontab_root_20240101.txt # Restaurar configuración de MySQL cp /tmp/etc-restore/etc/mysql/mariadb.conf.d/50-server.cnf \ /etc/mysql/mariadb.conf.d/ systemctl restart mariadb
Al restaurar configuraciones sobre un sistema en producción, siempre verificar la sintaxis antes de recargar el servicio.
El paso más ignorado y el más importante. Verificar regularmente que los backups son válidos y restaurables.
# Verificar integridad de archivo comprimido gunzip -t mi_base_20240101.sql.gz && echo "OK" || echo "CORRUPTO" gzip -t backup_www_20240101.tar.gz && echo "OK" || echo "CORRUPTO" # Verificar que el SQL tiene contenido real gunzip -c mi_base_20240101.sql.gz | head -20 gunzip -c mi_base_20240101.sql.gz | grep "CREATE TABLE" | wc -l # Verificar tamaño mínimo (un backup de 0 bytes es una señal de error) ls -lh /var/backups/mysql/*.sql.gz # Restauración de prueba en base temporal mysql -u root -p -e "CREATE DATABASE test_restore;" gunzip < mi_base_20240101.sql.gz | mysql -u root -p test_restore mysql -u root -p test_restore -e "SHOW TABLES; SELECT COUNT(*) FROM usuarios;" mysql -u root -p -e "DROP DATABASE test_restore;" # Verificar subida a la nube rclone check /var/backups/mysql/ r2:mi-bucket/vps/mysql/
Script que verifica cada mañana que el backup de anoche se hizo y no está corrupto. Guardar en /usr/local/bin/verificar-backups.sh
#!/bin/bash # verificar-backups.sh BACKUP_DIR="/var/backups/diario/mysql" EMAIL="tu@email.com" MIN_SIZE_KB=10 # tamaño mínimo esperado en KB ERRORES=0 REPORTE="" # Verificar que existe backup de hoy HOY="$(date +%Y%m%d)" BACKUPS_HOY="$(ls $BACKUP_DIR/*${HOY}*.sql.gz 2>/dev/null | wc -l)" if [ "$BACKUPS_HOY" -eq 0 ]; then REPORTE+="✗ No se encontraron backups de hoy ($HOY)\n" ERRORES=$(( ERRORES + 1 )) fi # Verificar integridad de cada backup de hoy for FILE in $BACKUP_DIR/*${HOY}*.sql.gz; do [ -f "$FILE" ] || continue NAME="$(basename $FILE)" # Verificar tamaño mínimo SIZE_KB="$(du -k "$FILE" | cut -f1)" if [ "$SIZE_KB" -lt "$MIN_SIZE_KB" ]; then REPORTE+="✗ $NAME — demasiado pequeño (${SIZE_KB}KB)\n" ERRORES=$(( ERRORES + 1 )) continue fi # Verificar integridad del gzip if gunzip -t "$FILE" 2>/dev/null; then REPORTE+="✓ $NAME (${SIZE_KB}KB) — OK\n" else REPORTE+="✗ $NAME — CORRUPTO\n" ERRORES=$(( ERRORES + 1 )) fi done # Enviar reporte if [ "$ERRORES" -gt 0 ]; then echo -e "$REPORTE" | mail -s "⚠ ERROR en backups VPS" "$EMAIL" else echo -e "$REPORTE" | mail -s "✓ Backups VPS OK — $HOY" "$EMAIL" fi
chmod +x /usr/local/bin/verificar-backups.sh
# Agregar a crontab — verificar a las 8 AM (backup corre a las 2 AM)
# 30 8 * * * /usr/local/bin/verificar-backups.sh
Una vez al mes, practicar la restauración completa en un ambiente de prueba. Si nunca lo hiciste, no sabés si tus backups realmente funcionan.
# Checklist del simulacro mensual: 1. Bajar el último backup desde la nube rclone copy r2:mi-bucket/vps/ /tmp/drill/ 2. Verificar integridad de todos los archivos find /tmp/drill -name "*.gz" -exec gunzip -t {} \; -print 3. Restaurar la base en una instancia de prueba mysql -u root -p -e "CREATE DATABASE drill_test;" gunzip < /tmp/drill/mysql/mi_base_*.sql.gz \ | mysql -u root -p drill_test 4. Verificar que los datos son correctos mysql -u root -p drill_test \ -e "SHOW TABLES; SELECT COUNT(*) FROM usuarios;" 5. Medir el tiempo de recuperación (RTO) time gunzip < backup.sql.gz | mysql -u root -p drill_test 6. Limpiar mysql -u root -p -e "DROP DATABASE drill_test;" rm -rf /tmp/drill/ # Documentar: # ¿Cuánto tiempo tomó restaurar? # ¿Hubo errores? # ¿Los datos eran coherentes?
Conocer el RTO (Recovery Time Objective) real de tu setup es tan importante como tener el backup. En una emergencia no es momento para descubrir que algo no funciona.