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 localhostla dirección del servidor[database_name]el nombre de la base a exportar| gzipcomprime 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:
- Crea el directorio si no existe
- Registra métricas del estado del disco antes y después
- Ejecuta el backup usando pg_dump
- Limpia los archivos más viejos que el período de retención
- 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
findcon-mtime +30y-deletelimpia 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:

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.
- En tu máquina local, ejecutar:
rclone authorize "drive" "[token_que_muestra_el_servidor]"
- Se abre el navegador → autorizar acceso a Google Drive
- Copiar el token JSON completo que aparece en la terminal
- Pegarlo en el servidor cuando pida
config_token> - 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):
- Ir al servidor de Discord
- Click derecho en el canal → Editar canal
- Integraciones → Webhooks → Nuevo Webhook
- Copiar la URL generada
- 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.