Saltar al contenido principal
Terminal con comandos de backup, calendario con fecha y un candado de seguridad.

Script Bash para backups automáticos de archivos y bases de datos

Si tenés un servidor en producción, tarde o temprano algo se va a romper. Un disco que se llena, una tabla que se borra por error, un rm -rf mal puesto, un update de sistema operativo que sale mal. En todos esos casos, la diferencia entre un “uh, qué mal” y un “se perdió todo” es si tenés un backup actualizado y, más importante, si sabés cómo restaurarlo.

En este post vamos a armar un script en Bash que se encargue de las dos cosas que más me pidieron a lo largo de los años: respaldar directorios de archivos (el código de la app, configs de Nginx, archivos subidos por usuarios) y exportar bases de datos MySQL. El script va a ser ejecutable a mano, pero pensado para que lo metas en un cron y te olvides de que existe.

Qué tiene que hacer el script

Antes de escribir una línea, listemos los requisitos. Esto es lo que yo necesito en cualquier servidor de producción:

  1. Respaldar archivos críticos comprimidos con tar.gz. En mi caso suele ser /var/www/miapp (el código), /etc/nginx (las configs del servidor web) y a veces /etc/letsencrypt (los certificados).
  2. Exportar todas las bases de datos MySQL con mysqldump, en formato .sql comprimido con gzip.
  3. Nombrar los archivos con la fecha para que sea fácil identificar cuándo se hizo cada backup.
  4. Borrar los backups viejos según una cantidad de días configurable, así no se llena el disco.
  5. Registrar todo en un log para poder revisar después qué pasó en cada ejecución.
  6. Fallar rápido si algo sale mal. Un script de backup que termina con código 0 cuando en realidad no pudo respaldar algo es peor que no tener backup, porque te da una falsa sensación de seguridad.

Con esos seis puntos claros, vamos a escribir el script.

El script completo

Acá está la versión final, comentada por secciones. Después de la versión completa explico cada bloque en detalle.

#!/bin/bash
#
# backup.sh - Script de backup automático para archivos y MySQL
# Uso: sudo ./backup.sh
# Crontab sugerido: 0 3 * * * /opt/scripts/backup.sh
#

set -euo pipefail

# ============================================
# CONFIGURACIÓN - EDITAR ESTOS VALORES
# ============================================
BACKUP_DIR="/var/backups/miapp"
LOG_FILE="/var/log/backup.log"
RETENTION_DAYS=7

# Directorios a respaldar (separados por espacio)
DIRS_TO_BACKUP=(
    "/var/www/miapp"
    "/etc/nginx"
    "/etc/letsencrypt"
)

# Credenciales de MySQL (idealmente leer de un archivo seguro)
MYSQL_USER="backup_user"
MYSQL_PASS="tu_password_seguro"
MYSQL_HOST="localhost"

# ============================================
# FIN DE LA CONFIGURACIÓN
# ============================================

FECHA=$(date +"%Y-%m-%d")
HORA=$(date +"%H%M%S")
DEST_DIR="$BACKUP_DIR/$FECHA"

# Función de logging
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

# Validaciones iniciales
if [[ $EUID -ne 0 ]]; then
    echo "Este script debe ejecutarse como root (sudo)"
    exit 1
fi

if ! command -v mysqldump &> /dev/null; then
    log "ERROR: mysqldump no está instalado"
    exit 1
fi

# Crear directorio del día
mkdir -p "$DEST_DIR"
log "Iniciando backup en $DEST_DIR"

# Backup de archivos
log "Respaldando directorios de archivos..."
for DIR in "${DIRS_TO_BACKUP[@]}"; do
    if [[ -d "$DIR" ]]; then
        NOMBRE=$(basename "$DIR")
        ARCHIVO="$DEST_DIR/files-${NOMBRE}-${FECHA}.tar.gz"
        tar -czf "$ARCHIVO" -C "$(dirname "$DIR")" "$NOMBRE" 2>> "$LOG_FILE"
        log "  ✓ $DIR -> $ARCHIVO"
    else
        log "  ⚠ Directorio no existe, se omite: $DIR"
    fi
done

# Backup de MySQL
log "Exportando bases de datos MySQL..."
DUMP_FILE="$DEST_DIR/mysql-${FECHA}-${HORA}.sql.gz"
mysqldump \
    --user="$MYSQL_USER" \
    --password="$MYSQL_PASS" \
    --host="$MYSQL_HOST" \
    --single-transaction \
    --quick \
    --routines \
    --triggers \
    --events \
    --all-databases \
    2>> "$LOG_FILE" | gzip > "$DUMP_FILE"

if [[ -s "$DUMP_FILE" ]]; then
    log "  ✓ MySQL dump -> $DUMP_FILE"
else
    log "  ✗ El dump de MySQL está vacío o falló"
    exit 1
fi

# Calcular tamaños
log "Resumen de tamaños:"
du -sh "$DEST_DIR"/* 2>> "$LOG_FILE" | tee -a "$LOG_FILE"

# Aplicar rotación: borrar backups más viejos que RETENTION_DAYS
log "Aplicando rotación: borrando backups con más de $RETENTION_DAYS días..."
find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \; 2>> "$LOG_FILE"
log "Rotación completada"

log "Backup finalizado correctamente"
exit 0

Copiá este script a /opt/scripts/backup.sh (o donde te quede cómodo), dale permisos de ejecución con chmod 700 y ya lo podés correr. Ahora vamos bloque por bloque.

Bloque 1: Configuración

Las primeras líneas después del shebang son las variables que vas a tocar cuando migres de servidor o cambies de proyecto. Por convención las pongo todas juntas al inicio para que sea fácil encontrarlas.

BACKUP_DIR="/var/backups/miapp"
LOG_FILE="/var/log/backup.log"
RETENTION_DAYS=7

BACKUP_DIR es donde van a quedar todos los backups organizados por fecha. LOG_FILE es el archivo donde se registra cada ejecución, ideal para debuggear cuando algo falla a las 3 AM. RETENTION_DAYS es cuántos días de historial querés conservar. Para un backup diario con retención semanal, 7 está bien. Si querés un mes de historial, 30.

El array DIRS_TO_BACKUP acepta varias rutas. Cada elemento se procesa por separado en el loop, así un directorio que no existe no rompe todo el backup.

DIRS_TO_BACKUP=(
    "/var/www/miapp"
    "/etc/nginx"
    "/etc/letsencrypt"
)

Para las credenciales de MySQL, hay una decisión importante que tomar. La opción más simple es hardcodear la contraseña en el script, pero eso es un riesgo de seguridad: si alguien ve el archivo, ve la contraseña de la base de datos. La opción más segura es leerla de un archivo separado con permisos 600:

# Archivo /root/.mybackup.cnf
[client]
user=backup_user
password=tu_password_seguro
host=localhost

Y en el script:

chmod 600 /root/.mybackup.cnf
mysqldump --defaults-file=/root/.mybackup.cnf ...

Si en tu proyecto tenés un usuario de MySQL dedicado solo para backups (lo cual es una buena práctica), esto te queda limpio. Si no querés complicarte, dejá la contraseña en el script pero asegurate de que el archivo tenga permisos 600 y sea solo accesible por root.

Bloque 2: set -euo pipefail y validaciones

Esta línea es la más importante del script y mucha gente la saltea:

set -euo pipefail

Cada flag hace algo:

  • -e: si cualquier comando falla, el script termina inmediatamente con código de error.
  • -u: si usás una variable que no está definida, el script falla (en lugar de tratarla como string vacío).
  • -o pipefail: si en un pipe como comando1 | comando2 falla comando1, el pipe entero se considera fallido. Sin esta flag, solo importa el exit code del último comando.

Juntas, estas tres flags hacen que el script falle rápido en lugar de seguir ejecutándose cuando algo salió mal. Es exactamente lo que querés en un script de backup.

Después vienen las validaciones previas a empezar a respaldar:

if [[ $EUID -ne 0 ]]; then
    echo "Este script debe ejecutarse como root (sudo)"
    exit 1
fi

if ! command -v mysqldump &> /dev/null; then
    log "ERROR: mysqldump no está instalado"
    exit 1
fi

La primera chequea que estés corriendo como root (necesario para leer /etc/letsencrypt y /etc/nginx). La segunda verifica que mysqldump esté instalado antes de seguir, así no llegás a la mitad del backup y te explotás.

Bloque 3: Backup de archivos

Acá es donde se comprimen los directorios. El comando tar con -czf crea un archivo .tar.gz y -C cambia al directorio padre para que el path dentro del archivo sea relativo.

for DIR in "${DIRS_TO_BACKUP[@]}"; do
    if [[ -d "$DIR" ]]; then
        NOMBRE=$(basename "$DIR")
        ARCHIVO="$DEST_DIR/files-${NOMBRE}-${FECHA}.tar.gz"
        tar -czf "$ARCHIVO" -C "$(dirname "$DIR")" "$NOMBRE" 2>> "$LOG_FILE"
        log "  ✓ $DIR -> $ARCHIVO"
    else
        log "  ⚠ Directorio no existe, se omite: $DIR"
    fi
done

La parte clave es el -C "$(dirname "$DIR")". Si haces tar -czf backup.tar.gz /var/www/miapp, cuando descomprimís te queda un directorio /var/www/miapp adentro, lo cual es un quilombo para restaurar. Con -C /var/www miapp te queda directamente miapp/ en la raíz del archivo, que es mucho más limpio.

Notá también que cada directorio se guarda en un archivo separado (files-www-2026-07-03.tar.gz, files-nginx-2026-07-03.tar.gz, etc). Esto es útil cuando necesitás restaurar un solo directorio sin tener que tocar los demás.

Bloque 4: Dump de MySQL

El comando mysqldump tiene un montón de flags, pero para un backup de producción estándar los que importan son estos:

mysqldump \
    --user="$MYSQL_USER" \
    --password="$MYSQL_PASS" \
    --host="$MYSQL_HOST" \
    --single-transaction \
    --quick \
    --routines \
    --triggers \
    --events \
    --all-databases
  • --single-transaction: hace el dump dentro de una transacción, así las tablas InnoDB no se lockean durante el proceso. Imprescindible si tu base está en producción.
  • --quick: hace que mysqldump vuelque las filas de a una en lugar de cargar todo en memoria. Es la diferencia entre que el script funcione con tablas grandes o que te tire un “out of memory”.
  • --routines, --triggers, --events: incluye stored procedures, triggers y eventos programados. Si no los ponés, el día que necesites restaurar te van a faltar cosas sin saber por qué.
  • --all-databases: exporta todas las bases. Si querés ser más selectivo, podés usar --databases db1 db2 db3 o incluso --ignore-table=db.tabla_grande_que_no_necesitas.

Después, el output se comprime con gzip en el mismo pipe:

| gzip > "$DUMP_FILE"

El chequeo posterior es importante:

if [[ -s "$DUMP_FILE" ]]; then
    log "  ✓ MySQL dump -> $DUMP_FILE"
else
    log "  ✗ El dump de MySQL está vacío o falló"
    exit 1
fi

-s chequea que el archivo exista y no esté vacío. Si el dump falló silenciosamente, este chequeo lo detecta. Hay un caso clásico donde esto salva: las credenciales están mal y mysqldump no puede conectarse, pero como el comando igual crea el archivo de salida (vacío), sin este chequeo creerías que tenés un backup válido.

Bloque 5: Rotación

Esta es la parte que evita que el disco se llene:

find "$BACKUP_DIR" -maxdepth 1 -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \;

El comando find busca directorios dentro de $BACKUP_DIR (sin recursionar, por eso -maxdepth 1) que tengan más de RETENTION_DAYS días de antigüedad y los borra con rm -rf. Los directorios con nombre 2026-07-03, 2026-07-04, etc. entran en esta categoría cuando son más viejos que el cutoff.

El -mtime +7 significa “más de 7 días” (estrictamente, son 8 o más). Si querés ser más preciso y conservar exactamente 7, usá -mtime +6 que cuenta días completos desde la última modificación.

Programación con cron

Una vez que el script está probado y funcionando, lo metés en un cron para que corra solo:

sudo crontab -e

Y agregás una línea como esta:

0 3 * * * /opt/scripts/backup.sh >> /var/log/backup-cron.log 2>&1

Esto corre todos los días a las 3:00 AM. La hora es importante: elegí un momento de poca actividad en tu sitio. Las 3 AM es la convención porque es cuando casi nadie usa internet. La parte >> /var/log/backup-cron.log 2>&1 redirige la salida estándar y los errores a un archivo separado, así no perdés información si algo sale mal.

Para verificar que el cron está bien configurado, podés listar las tareas con crontab -l. Y para testear, cambiá la hora a un par de minutos en el futuro y esperá.

Cómo restaurar (lo que casi nadie testea)

Hacer un backup es la mitad del trabajo. La otra mitad es poder restaurarlo. Te dejo los comandos básicos para que los pruebes antes de necesitarlos en una emergencia.

Para restaurar un directorio desde un .tar.gz:

# Restaurar en el directorio actual
tar -xzf files-www-2026-07-03.tar.gz

# Restaurar en una ruta específica
tar -xzf files-www-2026-07-03.tar.gz -C /

Para restaurar la base de datos MySQL:

# Descomprimir el dump
gunzip mysql-2026-07-03-030000.sql.gz

# Importar todas las bases
mysql -u root -p < mysql-2026-07-03-030000.sql

Mi recomendación: hacé un restore de prueba en otro servidor (o en un contenedor Docker) cada tres meses, así te asegurás de que el backup se pueda restaurar y de que sabés los comandos de memoria cuando los necesitás. Un backup que no se restaura no es un backup.

Mejoras opcionales que valen la pena

Una vez que el script base funcione, hay varias mejoras que podés sumarle según tu proyecto:

Subir a un servidor remoto con rsync:

rsync -avz --delete "$BACKUP_DIR/" backup@backup-server:/backups/miapp/

Notificación por email cuando termina:

echo "Backup completado en $(date)" | mail -s "Backup OK" tu@email.com

Encriptar el backup con GPG antes de subirlo:

gpg --symmetric --cipher-algo AES256 "$DUMP_FILE"

Verificar la integridad con un hash:

md5sum "$DUMP_FILE" > "$DUMP_FILE.md5"

# Al restaurar, verificar
md5sum -c mysql-2026-07-03-030000.sql.gz.md5

Lock file para evitar ejecuciones simultáneas:

LOCKFILE="/var/run/backup.lock"
if [ -e "$LOCKFILE" ]; then
    log "ERROR: ya hay un backup en ejecución"
    exit 1
fi
touch "$LOCKFILE"
trap "rm -f $LOCKFILE" EXIT

Esta última es clave si tu backup puede llegar a demorar más de un día por algún motivo, o si el cron se superpone con una corrida anterior.

Errores comunes que vale la pena evitar

  • No probar el restore. El error más caro de todos. Un backup no es válido hasta que lo restaurás al menos una vez.
  • Dejar la contraseña de MySQL hardcodeada y el script con permisos 644. Cualquier usuario del sistema puede leer tu base de datos. Usá chmod 600 siempre.
  • No poner set -e y tener un script que “termina bien” cuando falló a mitad de camino. El exit code 0 te miente, el cron te avisa con un mail, pero si no configurás el mail te quedás sin saber.
  • Olvidar la regla 3-2-1 de backups. Tres copias, en dos medios distintos, una fuera del servidor. Si el servidor se incendia y el backup estaba en el mismo disco, perdiste todo.
  • Hacer el backup en el mismo disco que la base de datos. Si el disco muere, te llevás puesta la base y el backup juntos.

Conclusión

Con este script ya tenés una base sólida para respaldar tu servidor. Es la versión que uso en mis proyectos personales y la he ido refinando con el tiempo. La parte más importante no es el script en sí, sino el hábito de probarlo periódicamente y de mantener la rotación para no quedarte sin espacio.

En el próximo post vamos a ver otra cosa clave: monitoreo de recursos del servidor con Bash, así podés anticiparte a problemas antes de que se conviertan en caídas. Si tenés alguna mejora al script o un caso de uso particular, me encantaría leerlo en los comentarios.