fran_dev
← VOLVER

AUTOMATIZACIÓN DE BACKUPS CON POSTGRESQL, BASH Y RCLONE

AUTOMATIZACIÓN DE BACKUPS CON POSTGRESQL, BASH Y RCLONE

Es sabido que los programadores tendemos a la vagancia y que solemos fantasear con una reposera en la playa mientras un script perfectamente pulido hace el trabajo por nosotros. También sabemos que esa fantasía es imposible, pero eso no nos detiene en la búsqueda de resolver el trabajo tedioso.

El caso

Hace un par de meses, me tocó desarrollar una aplicación web para un cliente utilizando Next.js y PostgreSQL en un VPS con Ubuntu Server, con gestión de usuarios y un catálogo de productos. Para complementar el servicio de mantenimiento, decidí incorporar backups semanales que permitan resguardar los datos ante cualquier imprevisto.

Ahora bien, ¿cómo se puede abordar esta situación? Cuando emprendí la búsqueda me topé con las bondades de bash (un clásico lenguaje de script que conocí en el primer año de la universidad) y systemd, el sistema de gestión de servicios estándar en Linux.

Dejo algunas referencias:

Esquema del flujo y estructura de archivos en el servidor

El esquema básico se puede resumir de la siguiente manera:

Base de datos → backup semanal → almacenamiento local → sincronización en la nube → notificación

Por otro lado, también hay que definir una estructura de carpetas adecuada para este tipo de servicios:

/usr/local/bin/
├── postgres-backup.sh           # Script principal de backup
├── postgres-backup-gdrive.sh    # Sincronización a Google Drive
└── postgres-restore.sh          # Script de restauración

/etc/systemd/system/
├── postgres-backup.service      # Servicio de backup
├── postgres-backup.timer        # Timer (sábados 3:00 AM)
├── postgres-backup-gdrive.service   # Servicio de sync a Google Drive
└── postgres-backup-gdrive.timer     # Timer sync (sábados 3:15 AM)

/var/backups/postgresql/         # Backups locales

1. Backup local

1.1 Crear el directorio de backups

sudo mkdir -p /var/backups/postgresql
sudo chown [user]:[user] /var/backups/postgresql
sudo chmod 700 /var/backups/postgresql

1.2 ¿Cómo se hace un backup en PostgreSQL?

PostgreSQL incluye pg_dump, una herramienta nativa que exporta una base de datos completa a un archivo SQL. Combinada con gzip, se puede comprimir el resultado para ahorrar espacio en disco. El comando base es el siguiente:

pg_dump -U [user] -h localhost [database_name] | gzip > backup.sql.gz
  • -U [user] indica el usuario de PostgreSQL
  • -h localhost la dirección del servidor
  • [database_name] el nombre de la base a exportar
  • | gzip comprime la salida en tiempo real

La documentación completa se puede consultar en https://www.postgresql.org/docs/current/app-pgdump.html.

Como no había requerimientos específicos del cliente en cuanto a frecuencia, definí un backup semanal con retención de 30 días. Dada las características del proyecto, alcanza para recuperarse ante cualquier error y no comprometer la cantidad de espacio disponible en el servidor.

1.3 Script principal de backup

El script orquesta el proceso completo a partir de la siguiente secuencia:

  1. Crea el directorio si no existe
  2. Registra métricas del estado del disco antes y después
  3. Ejecuta el backup usando pg_dump
  4. Limpia los archivos más viejos que el período de retención
  5. Envía una notificación a Discord con el resultado

Archivo: /usr/local/bin/postgres-backup.sh

#!/bin/bash

# ============================================
# Configuración
# ============================================
# ====> Reemplazar con los datos de tu base de datos
DB_NAME="[database_name]"
DB_USER="[user]"
export PGPASSWORD="[password]"

BACKUP_DIR="/var/backups/postgresql"
RETENTION_DAYS=30
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}_${DATE}.sql.gz"

DISCORD_WEBHOOK="https://discord.com/api/webhooks/[tu_webhook_aqui]"

# ============================================
# Funciones
# ============================================

get_disk_info() {
    df -h "$BACKUP_DIR" | awk 'NR==2 {print $4}'
}

get_disk_usage() {
    df -h "$BACKUP_DIR" | awk 'NR==2 {print $5}'
}

get_backup_dir_size() {
    du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1
}

get_backup_count() {
    find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -type f | wc -l
}

send_discord() {
    local message="$1"
    local color="$2"

    curl -H "Content-Type: application/json" \
         -X POST \
         -d "{
           \"embeds\": [{
             \"title\": \"🗄️ Backup PostgreSQL\",
             \"description\": \"$message\",
             \"color\": $color,
             \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\",
             \"footer\": {
               \"text\": \"$(hostname)\"
             }
           }]
         }" \
         "$DISCORD_WEBHOOK" 2>/dev/null
}

# ============================================
# Proceso de backup
# ============================================

mkdir -p "$BACKUP_DIR"

START_TIME=$(date +%s)
DISK_FREE_BEFORE=$(get_disk_info)
DISK_USAGE=$(get_disk_usage)
BACKUP_COUNT_BEFORE=$(get_backup_count)

send_discord "⏳ **Iniciando backup**\n\n📊 **Estado del sistema:**\n💾 Base de datos: \`${DB_NAME}\`\n💿 Espacio disponible: \`${DISK_FREE_BEFORE}\`\n📈 Uso del disco: \`${DISK_USAGE}\`\n📦 Backups existentes: \`${BACKUP_COUNT_BEFORE}\`" 3447003

pg_dump -U "$DB_USER" -h localhost "$DB_NAME" | gzip > "$BACKUP_FILE" 2>/tmp/backup_error.log

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
MINUTES=$((DURATION / 60))
SECONDS=$((DURATION % 60))

if [ $? -eq 0 ] && [ -f "$BACKUP_FILE" ]; then
    BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
    DISK_FREE_AFTER=$(get_disk_info)
    BACKUP_DIR_SIZE=$(get_backup_dir_size)

    OLD_FILES=$(find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -type f -mtime +$RETENTION_DAYS)
    OLD_COUNT=$(echo "$OLD_FILES" | grep -c "sql.gz")

    if [ $OLD_COUNT -gt 0 ]; then
        OLD_SIZE=$(echo "$OLD_FILES" | xargs du -ch 2>/dev/null | tail -1 | cut -f1)
        find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -type f -mtime +$RETENTION_DAYS -delete
    else
        OLD_SIZE="0B"
    fi

    BACKUP_COUNT_AFTER=$(get_backup_count)

    SUCCESS_MSG="✅ **Backup completado exitosamente**\n\n"
    SUCCESS_MSG+="⏱️ **Tiempo de ejecución:** \`${MINUTES}m ${SECONDS}s\`\n\n"
    SUCCESS_MSG+="📦 **Información del backup:**\n"
    SUCCESS_MSG+="├─ Archivo: \`${DB_NAME}_${DATE}.sql.gz\`\n"
    SUCCESS_MSG+="├─ Tamaño: \`${BACKUP_SIZE}\`\n"
    SUCCESS_MSG+="└─ Ubicación: \`${BACKUP_DIR}\`\n\n"
    SUCCESS_MSG+="🗑️ **Limpieza:**\n"
    SUCCESS_MSG+="├─ Backups eliminados: \`${OLD_COUNT}\`\n"
    SUCCESS_MSG+="└─ Espacio liberado: \`${OLD_SIZE}\`\n\n"
    SUCCESS_MSG+="💾 **Estado del disco:**\n"
    SUCCESS_MSG+="├─ Espacio disponible: \`${DISK_FREE_AFTER}\`\n"
    SUCCESS_MSG+="├─ Uso del disco: \`${DISK_USAGE}\`\n"
    SUCCESS_MSG+="├─ Backups actuales: \`${BACKUP_COUNT_AFTER}\`\n"
    SUCCESS_MSG+="└─ Tamaño total backups: \`${BACKUP_DIR_SIZE}\`"

    send_discord "$SUCCESS_MSG" 65280

    exit 0
else
    ERROR_MSG=$(cat /tmp/backup_error.log 2>/dev/null || echo "Error desconocido")
    DISK_FREE_AFTER=$(get_disk_info)

    FAIL_MSG="❌ **Error en el backup**\n\n"
    FAIL_MSG+="⏱️ **Tiempo transcurrido:** \`${MINUTES}m ${SECONDS}s\`\n\n"
    FAIL_MSG+="🔴 **Error:**\n\`\`\`\n${ERROR_MSG}\`\`\`\n\n"
    FAIL_MSG+="💾 **Estado del disco:**\n"
    FAIL_MSG+="├─ Espacio disponible: \`${DISK_FREE_AFTER}\`\n"
    FAIL_MSG+="└─ Uso del disco: \`${DISK_USAGE}\`\n\n"
    FAIL_MSG+="⚠️ Por favor, revisar el servidor."

    send_discord "$FAIL_MSG" 16711680

    exit 1
fi

Algunas cosas que vale la pena mencionar en el script:

  • Retención automática: el find con -mtime +30 y -delete limpia los archivos más viejos que 30 días en el mismo paso que valida el éxito del backup, así nunca se acumulan archivos viejos.
  • Métricas pre/post: capturamos el estado del disco antes y después del backup para tener visibilidad real de lo que está pasando en el servidor.

Les damos los permisos correspondientes:

sudo chown [user]:[user] /usr/local/bin/postgres-backup.sh
sudo chmod +x /usr/local/bin/postgres-backup.sh

1.4 Servicio systemd

Una vez creado el script de bash, tenemos que poder incluirlo como parte de los servicios del SO para poder ejecutarlo una vez por semana.

Archivo: /etc/systemd/system/postgres-backup.service

[Unit]
Description=Backup Semanal PostgreSQL
After=postgresql.service

[Service]
Type=oneshot
User=[user]
ExecStart=/usr/local/bin/postgres-backup.sh

[Install]
WantedBy=multi-user.target

1.5 Timer systemd

Con systemd no necesitamos un cron job: el .timer actúa como disparador y tiene la ventaja de ejecutarse incluso si el servidor estuvo apagado a la hora programada (Persistent=true).

Archivo: /etc/systemd/system/postgres-backup.timer

[Unit]
Description=Timer para Backup PostgreSQL (sábados 3:00 AM)

[Timer]
OnCalendar=Sat *-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

1.6 Activar el servicio

Finalmente lo activamos:

sudo systemctl daemon-reload
sudo systemctl enable postgres-backup.timer
sudo systemctl start postgres-backup.timer

2. Sincronización en la nube con rclone

Tener el backup solo en el servidor no alcanza ya que, si tenemos un problema en el servidor puede perderse información valiosa como bien explica Leo DiCaprio:

backup meme

En este caso me decidí por Google Drive ya que es un servicio accesible y que suelo usar en el día a día. Para sincronizar los archivos a Google Drive recurrí a rclone, una herramienta open source que soporta distintos tipos de proveedores de almacenamiento en la nube y funciona perfecto desde la terminal.

2.1 Instalar rclone

curl https://rclone.org/install.sh | sudo bash
rclone version

2.2 Configurar Google Drive

rclone config

Seguir la siguiente secuencia de respuestas:

n/s/q> n
name> gdrive
Storage> drive
client_id> [ENTER - vacío]
client_secret> [ENTER - vacío]
scope> 1
service_account_file> [ENTER - vacío]
y/n> n   # no usar configuración avanzada
y/n> n   # no usar navegador web (el servidor no tiene GUI)

Autorización desde la máquina local:

Como el servidor no tiene interfaz gráfica, el paso de autorización con Google se hace desde la máquina local y se copia el token resultante al servidor.

  1. En tu máquina local, ejecutar:
rclone authorize "drive" "[token_que_muestra_el_servidor]"
  1. Se abre el navegador → autorizar acceso a Google Drive
  2. Copiar el token JSON completo que aparece en la terminal
  3. Pegarlo en el servidor cuando pida config_token>
  4. Confirmar:
y/n> n   # no es Shared Drive
y/e/d> y # confirmar configuración
q        # salir

2.3 Probar la conexión

rclone ls gdrive:
rclone about gdrive:

2.4 Crear carpetas en Google Drive

rclone mkdir gdrive:Backups
rclone mkdir gdrive:Backups/PostgreSQL
rclone lsd gdrive:Backups

2.5 Script de sincronización

Al igual que en el paso del backup, tenemos que crear el script de bash, luego el servicio systemd y finalmente setear el timer.

Archivo: /usr/local/bin/postgres-backup-gdrive.sh

#!/bin/bash

# ============================================
# Configuración
# ============================================
LOCAL_BACKUP_DIR="/var/backups/postgresql"
REMOTE_NAME="gdrive"
REMOTE_DIR="Backups/PostgreSQL"
DISCORD_WEBHOOK="https://discord.com/api/webhooks/[tu_webhook_aqui]"

# ============================================
# Funciones
# ============================================

send_discord() {
    local message="$1"
    local color="$2"

    curl -H "Content-Type: application/json" \
         -X POST \
         -d "{
           \"embeds\": [{
             \"title\": \"☁️ Google Drive Backup\",
             \"description\": \"$message\",
             \"color\": $color,
             \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\",
             \"footer\": {
               \"text\": \"$(hostname)\"
             }
           }]
         }" \
         "$DISCORD_WEBHOOK" 2>/dev/null
}

# ============================================
# Proceso de sincronización
# ============================================

START_TIME=$(date +%s)

LOCAL_COUNT=$(find $LOCAL_BACKUP_DIR -name "*.sql.gz" -type f | wc -l)
LOCAL_SIZE=$(du -sh $LOCAL_BACKUP_DIR | cut -f1)

send_discord "⏳ **Iniciando sync a Google Drive**\n\n📦 Archivos locales: \`${LOCAL_COUNT}\`\n💾 Tamaño: \`${LOCAL_SIZE}\`" 3447003

rclone sync $LOCAL_BACKUP_DIR $REMOTE_NAME:$REMOTE_DIR \
    --progress \
    --transfers 4 \
    --checkers 8 \
    --drive-chunk-size 64M \
    --log-file /tmp/rclone_gdrive.log \
    --log-level INFO

RCLONE_EXIT=$?

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
MINUTES=$((DURATION / 60))
SECONDS=$((DURATION % 60))

if [ $RCLONE_EXIT -eq 0 ]; then
    REMOTE_COUNT=$(rclone lsf $REMOTE_NAME:$REMOTE_DIR | wc -l)
    REMOTE_SIZE=$(rclone size $REMOTE_NAME:$REMOTE_DIR --json | python3 -c "import sys, json; print(json.load(sys.stdin)['bytes'])" | numfmt --to=iec-i --suffix=B 2>/dev/null || echo "N/A")

    SUCCESS_MSG="✅ **Sync a Google Drive completado**\n\n"
    SUCCESS_MSG+="⏱️ **Tiempo:** \`${MINUTES}m ${SECONDS}s\`\n\n"
    SUCCESS_MSG+="📊 **Resultado:**\n"
    SUCCESS_MSG+="├─ Archivos locales: \`${LOCAL_COUNT}\`\n"
    SUCCESS_MSG+="├─ Archivos en Drive: \`${REMOTE_COUNT}\`\n"
    SUCCESS_MSG+="├─ Tamaño local: \`${LOCAL_SIZE}\`\n"
    SUCCESS_MSG+="└─ Tamaño en Drive: \`${REMOTE_SIZE}\`"

    send_discord "$SUCCESS_MSG" 65280
    exit 0
else
    ERROR_LOG=$(tail -10 /tmp/rclone_gdrive.log)

    FAIL_MSG="❌ **Error en sync a Google Drive**\n\n"
    FAIL_MSG+="⏱️ **Tiempo:** \`${MINUTES}m ${SECONDS}s\`\n\n"
    FAIL_MSG+="🔴 **Error:**\n\`\`\`\n${ERROR_LOG}\`\`\`"

    send_discord "$FAIL_MSG" 16711680
    exit 1
fi

Permisos:

sudo chown [user]:[user] /usr/local/bin/postgres-backup-gdrive.sh
sudo chmod +x /usr/local/bin/postgres-backup-gdrive.sh

2.6 Servicio systemd para Google Drive

Archivo: /etc/systemd/system/postgres-backup-gdrive.service

[Unit]
Description=Sync Backups PostgreSQL a Google Drive
After=postgres-backup.service

[Service]
Type=oneshot
User=[user]
ExecStart=/usr/local/bin/postgres-backup-gdrive.sh

[Install]
WantedBy=multi-user.target

2.7 Timer systemd para Google Drive

Se programa 15 minutos después del backup para garantizar que el archivo ya esté generado antes de intentar subirlo.

Archivo: /etc/systemd/system/postgres-backup-gdrive.timer

[Unit]
Description=Timer Sync Google Drive (sábados 3:15 AM)

[Timer]
OnCalendar=Sat *-*-* 03:15:00
Persistent=true

[Install]
WantedBy=timers.target

2.8 Activar el servicio

sudo systemctl daemon-reload
sudo systemctl enable postgres-backup-gdrive.timer
sudo systemctl start postgres-backup-gdrive.timer

2.9 Probar manualmente

sudo -u [user] /usr/local/bin/postgres-backup-gdrive.sh

# Verificar en Google Drive
rclone ls gdrive:Backups/PostgreSQL
rclone size gdrive:Backups/PostgreSQL

3. Notificaciones en Discord

Como seguramente notaron en los scripts, hay una variable llamada DISCORD_WEBHOOK. La idea es simple: Discord permite crear webhooks en cualquier canal y usarlos para enviar mensajes programáticamente a través de un curl. Sin bots ni librerías externas.

¿Cómo crear el webhook? (documentación oficial):

  1. Ir al servidor de Discord
  2. Click derecho en el canal → Editar canal
  3. IntegracionesWebhooksNuevo Webhook
  4. Copiar la URL generada
  5. Pegarla en los scripts en la variable DISCORD_WEBHOOK

El cuerpo del mensaje usa el formato de embeds de Discord, que permite darle estilo con título, color, timestamp y footer. El campo color acepta un número entero que representa el color en decimal (por ejemplo, 65280 es verde, 16711680 es rojo). Hay convertidores online que permiten pasar de hex a decimal fácilmente.


4. Restauración

Ojalá nunca tengan que usarlo, pero conviene tenerlo listo antes de necesitarlo.

El script de restauración es interactivo: lista los backups disponibles, te deja elegir cuál restaurar y ofrece dos modos: crear una base de datos nueva o reemplazar la existente. En el segundo caso, hace un backup de seguridad automático antes de borrar nada.

Archivo: /usr/local/bin/postgres-restore.sh

#!/bin/bash

BACKUP_DIR="/var/backups/postgresql"
DB_USER="[user]"
DB_NAME="[database_name]"

GREEN='\033[0;32m'
BLUE='\033[0;34m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'

echo -e "${BLUE}📦 Backups disponibles:${NC}"
ls -lh $BACKUP_DIR/*.sql.gz | nl

echo ""
read -p "Ingresá el número del backup a restaurar: " CHOICE

if [[ "$CHOICE" =~ ^[0-9]+$ ]]; then
    BACKUP_FILE=$(ls -1 $BACKUP_DIR/*.sql.gz | sed -n "${CHOICE}p")
else
    BACKUP_FILE="$BACKUP_DIR/$CHOICE"
fi

if [ ! -f "$BACKUP_FILE" ]; then
    echo -e "${RED}❌ Archivo no encontrado${NC}"
    exit 1
fi

echo -e "${BLUE}📂 Archivo: $(basename $BACKUP_FILE)${NC}"

echo ""
echo "Opciones de restauración:"
echo "  1) Crear BD nueva (limpia)"
echo "  2) Recrear BD existente (DROP + CREATE — borra todo)"
read -p "Seleccioná [1/2]: " MODE

case $MODE in
    1)
        read -p "Nombre para la nueva BD: " NEW_DB
        echo -e "${BLUE}🔨 Creando base de datos $NEW_DB...${NC}"

        sudo -u postgres psql -c "DROP DATABASE IF EXISTS $NEW_DB;"
        sudo -u postgres psql -c "CREATE DATABASE $NEW_DB OWNER $DB_USER;"

        TARGET_DB=$NEW_DB
        ;;
    2)
        echo -e "${RED}⚠️  ADVERTENCIA: Esto BORRARÁ completamente la BD '$DB_NAME' y la recreará${NC}"
        read -p "Escribí 'BORRAR' para confirmar: " CONFIRM
        if [ "$CONFIRM" != "BORRAR" ]; then
            echo "Cancelado"
            exit 0
        fi

        SAFETY_BACKUP="/tmp/${DB_NAME}_before_restore_$(date +%Y%m%d_%H%M%S).sql.gz"
        echo -e "${YELLOW}💾 Haciendo backup de seguridad en: $SAFETY_BACKUP${NC}"
        pg_dump -U $DB_USER -h localhost $DB_NAME | gzip > "$SAFETY_BACKUP"

        echo -e "${BLUE}🗑️  Eliminando BD existente...${NC}"
        sudo -u postgres psql << EOF
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();

DROP DATABASE IF EXISTS $DB_NAME;
CREATE DATABASE $DB_NAME OWNER $DB_USER;
EOF

        TARGET_DB=$DB_NAME
        ;;
    *)
        echo "Opción inválida"
        exit 1
        ;;
esac

echo -e "${BLUE}🔄 Restaurando backup...${NC}"
gunzip -c "$BACKUP_FILE" | psql -U $DB_USER -h localhost -d $TARGET_DB

if [ ${PIPESTATUS[1]} -eq 0 ]; then
    echo -e "${GREEN}✅ Restauración completada exitosamente${NC}"
    echo -e "BD: $TARGET_DB"

    echo -e "\n${BLUE}📊 Verificando tablas restauradas:${NC}"
    psql -U $DB_USER -h localhost -d $TARGET_DB -c "
        SELECT
            schemaname || '.' || '\"' || tablename || '\"' as tabla,
            pg_size_pretty(pg_total_relation_size('public.\"'||tablename||'\"')) AS size
        FROM pg_tables
        WHERE schemaname = 'public'
        ORDER BY tablename;"
else
    echo -e "${RED}❌ Error en la restauración${NC}"
    if [ "$MODE" = "2" ]; then
        echo -e "${YELLOW}💾 Podés recuperar desde: $SAFETY_BACKUP${NC}"
    fi
    exit 1
fi

Permisos:

sudo chmod +x /usr/local/bin/postgres-restore.sh

Cómo usarlo:

Dejo un par de opciones para que lo puedan incorporar según los casos de uso

# Script interactivo
sudo /usr/local/bin/postgres-restore.sh

# Restauración manual rápida
sudo -u postgres psql << 'EOF'
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '[database_name]';
DROP DATABASE [database_name];
CREATE DATABASE [database_name] OWNER [user];
EOF

gunzip -c /var/backups/postgresql/[database_name]_XXXXXX.sql.gz | psql -U [user] -h localhost -d [database_name]

# Restaurar desde Google Drive
rclone copy gdrive:Backups/PostgreSQL/[database_name]_XXXXXX.sql.gz /tmp/
gunzip -c /tmp/[database_name]_XXXXXX.sql.gz | psql -U [user] -h localhost -d [database_name]

Espero que les sirva. Cualquier comentario, duda o consulta pueden escribirme a franjuaache@gmail.com.

▪ ▪ ▪