Backups automáticos de Postgres que realmente puedes restaurar
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Una tarea cron que corre pg_dump cada noche y escribe un archivo en algún lado no es un sistema de backup. Es un sistema de backup el día que alguien realmente necesita restaurar desde ahí, no antes. Muchos equipos lo descubren de la peor manera: la entrada de cron dejó de ejecutarse hace tres meses por un cambio en el PATH que nadie notó, o el backup nocturno lleva semanas escribiendo un mensaje de error de 40 bytes en un archivo .dump en lugar de una base de datos, o los backups están sentados justo al lado de la base que se supone que protegen y ambos desaparecen en la misma caída de servidor. Un archivo en disco que nadie ha restaurado nunca es una suposición, no un backup.
Al final de este tutorial vas a tener un sistema real: un pg_dump scripteado en el formato correcto, una programación de cron que realmente lo ejecuta, el backup enviado a un almacenamiento que no está en el mismo servidor, una política de retención que elimina copias viejas sin dejarte en cero backups y, lo más importante, una tarea programada que restaura tu último backup en una base de datos descartable y verifica que esté realmente bien. Esa última parte es la diferencia entre un backup y una esperanza.
Lo que necesitas
- Un servidor Postgres que controlas: un VPS, una máquina dedicada, o cualquier servidor self-hosted corriendo Postgres con
pg_dump,pg_restore,psqlycreatedb/dropdbdisponibles. - Un destino fuera del servidor para los backups: almacenamiento de objetos compatible con S3 (AWS S3, DigitalOcean Spaces, Backblaze B2, Cloudflare R2 o similar) y ya sea la AWS CLI o
rcloneconfigurado con credenciales para acceder a él. Todo el sentido de este ejercicio es que el backup no viva en el mismo disco que la base de datos, así que saltarse este paso anula el sistema. - Acceso de shell para programar tareas cron en el servidor Postgres (o donde sea que corras tareas programadas).
- Suficiente espacio en disco en el servidor para que un backup aterrice localmente antes de enviarse, y el mismo espacio otra vez para una base de datos de prueba de restauración durante el paso de verificación.
Un dominio no forma parte de esto. Estás respaldando datos, no sirviendo tráfico.
Paso 1: scriptear el dump
Usa el formato personalizado de pg_dump (-Fc, equivalente a --format=custom). Viene comprimido por defecto y, a diferencia de un dump SQL en texto plano, es lo que pg_restore espera, lo que te da restauraciones selectivas y un formato que pg_restore puede paralelizar más adelante si lo necesitas.
Agrega --no-owner y --no-acl para que el dump no incluya sentencias ALTER OWNER y GRANT/REVOKE ligadas a roles que podrían no existir (o tener nombres distintos) donde eventualmente lo restaures, incluyendo tu base de verificación en el paso 5.
#!/usr/bin/env bash
set -euo pipefail
DB_NAME="myapp_production"
DB_USER="myapp"
BACKUP_DIR="/var/backups/postgres"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
DUMP_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.dump"
mkdir -p "${BACKUP_DIR}"
pg_dump -Fc --no-owner --no-acl \
-U "${DB_USER}" -d "${DB_NAME}" \
-f "${DUMP_FILE}"
if [ ! -s "${DUMP_FILE}" ]; then
echo "pg_dump produced an empty file, aborting" >&2
exit 1
fi
echo "Backed up ${DB_NAME} to ${DUMP_FILE}"
set -euo pipefail importa acá: sin eso, un pg_dump que falla puede dejar que el script siga adelante y reporte éxito igual. La verificación [ ! -s "${DUMP_FILE}" ] detecta el caso específico donde pg_dump termina limpio pero el archivo que escribió está vacío, algo que pasa más seguido de lo que uno esperaría cuando el disco de destino está lleno.
Deberías ver un archivo nuevo en /var/backups/postgres con un tamaño que sigue más o menos el tamaño real de tu base de datos (un dump en formato personalizado va comprimido, así que no esperes una correspondencia 1:1, pero si una base de 500 MB produce un archivo de 2 KB, es señal de que algo anda mal).
Paso 2: programarlo con cron
Los campos de un crontab van en este orden: minuto, hora, día del mes, mes, día de la semana. Una ejecución nocturna a las 2:00 am se ve así:
0 2 * * * /usr/local/bin/pg_backup.sh >> /var/log/pg_backup.log 2>&1
Dos detalles que causan la mayoría de los incidentes de “mis backups dejaron de correr sin que nadie se diera cuenta”:
- cron corre con un ambiente mínimo, no el de tu shell interactiva. Si tu script llama a
pg_dump,awsorclonepor nombre y depende delPATHde tu shell para encontrarlos, elPATHreducido de cron puede no incluir el directorio donde viven. Usa rutas absolutas dentro del script (/usr/bin/pg_dump,/usr/local/bin/aws) o definePATHexplícitamente al inicio del script. - Redirige tanto stdout como stderr a un archivo de log (el
>> ... 2>&1de arriba). Sin eso, cron manda los fallos por correo a un buzón que nadie lee, o los descarta en silencio según la configuración del sistema, y solo te enteras cuando una restauración falla.
Deberías ver una línea nueva agregada a /var/log/pg_backup.log cada noche, y un archivo .dump nuevo en /var/backups/postgres con una marca de tiempo fresca. Si pasa un día sin archivo nuevo y sin línea nueva en el log, cron directamente no ejecutó la tarea, lo cual es un fallo distinto a que la tarea se ejecute y falle.
Paso 3: enviar el dump fuera del servidor
Un dump que se queda en el mismo disco que la base de datos que respalda no te protege de la falla que más importa: perder el servidor. Envíalo a otro lado apenas se escriba.
Con la AWS CLI, hacia S3 o cualquier endpoint compatible con S3:
aws s3 cp "${DUMP_FILE}" "s3://my-backups-bucket/postgres/" --only-show-errors
Para un proveedor compatible con S3 que no sea AWS, agrega --endpoint-url:
aws s3 cp "${DUMP_FILE}" "s3://my-backups-bucket/postgres/" \
--endpoint-url "https://nyc3.digitaloceanspaces.com" \
--only-show-errors
O con rclone, después de configurar un remote una vez con rclone config:
rclone copy "${DUMP_FILE}" "myremote:my-backups-bucket/postgres/"
Agrega cualquiera de los dos comandos al final de tu script de backup, y revisa su código de salida igual que revisaste el de pg_dump:
if ! aws s3 cp "${DUMP_FILE}" "s3://my-backups-bucket/postgres/" --only-show-errors; then
echo "upload to S3 failed for ${DUMP_FILE}" >&2
exit 1
fi
Deberías ver el objeto aparecer en tu bucket (aws s3 ls s3://my-backups-bucket/postgres/ o el equivalente en el dashboard de tu proveedor) casi apenas termina la tarea cron, con un tamaño que corresponde al archivo local.
Paso 4: eliminar backups viejos
Sin una política de retención, tu directorio de backups y tu bucket crecen para siempre. Con una política demasiado agresiva, puedes terminar eliminando hasta quedarte en cero backups justo cuando más los necesitas. La regla más segura tiene dos partes: conservar al menos un número mínimo de backups recientes sin importar su antigüedad, y por separado, borrar cualquiera que tenga más de N días y que no esté entre esos que conservaste.
En local:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/postgres"
KEEP_DAYS=30
KEEP_MIN=7
mapfile -t ALL_DUMPS < <(ls -1t "${BACKUP_DIR}"/*.dump 2>/dev/null)
for i in "${!ALL_DUMPS[@]}"; do
if [ "$i" -lt "${KEEP_MIN}" ]; then
continue
fi
file="${ALL_DUMPS[$i]}"
if [ -n "$(find "${file}" -mtime "+${KEEP_DAYS}")" ]; then
rm -f "${file}"
echo "pruned ${file}"
fi
done
ls -1t lista los dumps del más reciente al más viejo, así que el loop se salta incondicionalmente los KEEP_MIN archivos más recientes, y solo borra los más viejos que además fallan la prueba de antigüedad. Eso significa que una semana tranquila sin backups nuevos nunca te deja con menos de KEEP_MIN backups.
Para la copia remota, la mayoría de los proveedores compatibles con S3 ofrecen una regla de ciclo de vida del bucket que expira objetos después de N días, lo cual es más simple que scriptear la eliminación remota tú mismo; configúrala con un valor al menos tan largo como KEEP_DAYS para nunca terminar eliminando la única copia fuera del servidor que te queda.
Corre esto en una segunda entrada de cron, separada del script de backup, para que un bug en la limpieza no pueda correr por accidente dentro del mismo script que intenta crear un backup fresco:
30 2 * * * /usr/local/bin/pg_prune.sh >> /var/log/pg_backup.log 2>&1
Deberías ver desaparecer con el tiempo los archivos .dump viejos de /var/backups/postgres, mientras siempre se conservan al menos KEEP_MIN backups recientes, y una línea “pruned …” en el log cada vez que se elimina uno.
Paso 5: restaurar y verificar, de forma programada
Este es el paso que casi todos se saltan, y es el que realmente importa. Que un archivo de backup exista y tenga un tamaño distinto de cero te dice que pg_dump corrió. No te dice nada sobre si los datos adentro están íntegros, si el formato es válido, o si una restauración realmente funcionaría el día que la necesites. La única forma de saber que un backup está bien es restaurarlo y revisar qué sale.
Automatiza esto contra una base de datos descartable en el mismo servidor (o un servidor separado, si prefieres no agregarle carga de restauración a producción):
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/postgres"
LATEST_DUMP="$(ls -1t "${BACKUP_DIR}"/*.dump | head -n1)"
VERIFY_DB="restore_check_$(date +%Y%m%d)"
MIN_EXPECTED_ROWS=1
createdb "${VERIFY_DB}"
pg_restore --no-owner --no-acl -d "${VERIFY_DB}" "${LATEST_DUMP}"
ROW_COUNT="$(psql -d "${VERIFY_DB}" -tAc "SELECT count(*) FROM users;")"
dropdb "${VERIFY_DB}"
if [ "${ROW_COUNT}" -lt "${MIN_EXPECTED_ROWS}" ]; then
echo "restore verification failed: ${ROW_COUNT} rows in users table" >&2
exit 1
fi
echo "restore verified: ${LATEST_DUMP}, ${ROW_COUNT} rows in users table"
Cambia users por cualquier tabla sobre la que puedas afirmar razonablemente un número mínimo de filas; el punto de la consulta no es ser exhaustiva, es probar que la restauración produjo una base de datos funcional con datos reales adentro, no solo una base de datos que existe. El propio pg_restore también va a fallar ruidosamente ante un dump realmente corrupto o truncado, así que un código de salida distinto de cero en el paso de restauración ya es, por sí solo, una señal útil, independiente de la verificación del conteo de filas.
Prográmalo después de tu tarea de retención, y con menos frecuencia que el backup en sí; una vez por semana es un punto de partida razonable para la mayoría de los equipos:
0 3 * * 0 /usr/local/bin/pg_restore_check.sh >> /var/log/pg_backup.log 2>&1
Deberías ver una línea “restore verified: …” en el log cada semana, con un conteo de filas que se vea correcto para tus datos. Si esa línea deja de aparecer, o empieza a reportar un conteo de filas sospechosamente bajo, acabas de detectar un backup malo mientras todavía tienes backups buenos más viejos a los que recurrir, en vez de descubrirlo durante una caída real.
El atajo: hacerlo con Railyard
Todo lo anterior es la versión honesta, construida desde cero. Railyard corre el mismo ciclo por ti en cada base Postgres que administra, sin los scripts.
Cuando aprovisionas una base de datos en un servidor administrado por Railyard, se programan automáticamente sus propios backups con un intervalo que puedes ajustar por base de datos (un día es un valor por defecto razonable para la mayoría de las apps). Cada backup se verifica porque Railyard lo restaura de verdad y lo revisa, no solo comprueba que exista un archivo, así que puedes ver de un vistazo si tu backup más reciente está bien en vez de asumirlo. Restaurar es un clic desde la página de la base de datos, sin script que recordar y sin base de datos descartable que limpiar tú mismo después. La eliminación de backups viejos se maneja exactamente como la manejarías tú: Railyard conserva un número configurable de tus backups exitosos más recientes y elimina cualquiera que tenga más de un número configurable de días y que no esté entre esos que conservó, así que nunca estás a una mala limpieza de quedarte en cero.
Para Postgres en particular, también puedes activar la recuperación a un punto en el tiempo por servidor desde la página del servidor, lo cual complementa los backups programados: en vez de solo poder restaurar al momento de tu último backup, puedes recuperar cualquier instante que cubra la ventana de recuperación. Más detalles, incluyendo cómo se aprovisionan y conectan las bases de datos, están en Bases de datos y backups.
Si estás empezando una app nueva en vez de agregarle backups a una existente, la guía de inicio rápido y desplegar desde un repositorio cubren cómo poner en marcha la app y su base de datos en un servidor desde el principio.
Solución de problemas
La tarea cron funcionaba antes y ya no, sin ningún error en ningún lado. Casi siempre es el problema de PATH del paso 2: un script que funcionaba bien al probarlo de forma interactiva falla en silencio bajo el ambiente mínimo de cron, porque pg_dump, aws o rclone no están donde cron espera encontrarlos. Usa rutas absolutas para cada binario que el script invoque, o define PATH explícitamente al inicio del script, y confirma que la tarea realmente corre revisando la marca de tiempo del archivo de log, no solo que no haya aparecido un error.
El archivo de backup existe pero está vacío o truncado. La causa más común es que el disco de destino se llena a mitad de la escritura, sobre todo si el directorio de backups comparte disco con el directorio de datos de la propia base de datos o con un archivo de log que crece. pg_dump puede terminar con un código distinto de cero en este caso, que es exactamente por qué el script revisa el código de salida y el tamaño del archivo por separado en vez de confiar en uno solo.
Nadie probó nunca una restauración, y los backups llevan meses rotos en silencio. Este es el escenario que el paso 5 existe para prevenir. Un dump corrupto o incompleto puede quedarse en tu bucket viéndose perfectamente sano mientras nadie intente usarlo. Programar la tarea de restaurar y verificar, no solo documentarla como algo que se hará manualmente algún día, es lo que realmente cierra esta brecha.
Las credenciales del destino fuera del servidor vencieron o se renovaron. Una llave de acceso de S3 o las credenciales de un remote de rclone pueden vencer o revocarse de forma independiente a cualquier cosa que pase en el servidor Postgres, y el paso de subida va a empezar a fallar mientras el backup local sigue teniendo éxito. Revisar el código de salida del comando de subida (paso 3) detecta esto de inmediato, en vez de semanas después, cuando buscas una copia fuera del servidor que nunca se llegó a escribir.
Preguntas frecuentes
¿Con qué frecuencia deberían correr los backups? Depende de cuántos datos puedes darte el lujo de perder, tu objetivo de punto de recuperación. Diario es un valor por defecto razonable para la mayoría de las aplicaciones. Si tus datos cambian lo suficientemente rápido como para que perder incluso un día sea un problema real, corre pg_dump con más frecuencia o agrega archivado WAL para recuperación a un punto en el tiempo entre backups.
¿Alcanza con pg_dump, o necesito WAL y recuperación a un punto en el tiempo? pg_dump te da una foto limpia y consistente en el momento exacto en que corrió. El archivado WAL continuo (archive_mode, archive_command, y base backups periódicos con pg_basebackup) te permite reproducir cambios hacia adelante hasta un instante específico usando recovery_target_time, así que puedes recuperar justo antes de una mala migración o un borrado accidental, en vez de solo hasta el backup de anoche. Para la mayoría de los equipos, los backups programados con pg_dump cubren el caso común (pérdida de servidor, corrupción), y la recuperación a un punto en el tiempo vale la pena agregarla una vez que perder aunque sea unas horas de datos entre backups realmente te duela.
¿Cómo sé que un backup está realmente bien? Solo una restauración exitosa lo prueba. Que un archivo exista, que tenga un tamaño razonable, y que pg_dump termine con código 0 son todas condiciones necesarias pero no suficientes; la única prueba real es restaurarlo en algún lado y verificar que los datos que vuelven son los esperados, que es exactamente lo que automatiza el paso 5.
¿Dónde debería vivir realmente la copia fuera del servidor? En cualquier lado que no sea el mismo disco, idealmente ni siquiera el mismo proveedor o región que el servidor de la base de datos, para que una sola caída de proveedor o un problema de cuenta no pueda llevarse a la vez tu base de datos y sus backups.
¿Hace falta cifrar los archivos de backup? Si tu destino de almacenamiento de objetos soporta cifrado del lado del servidor en reposo, activarlo no cuesta nada y vale la pena. Si quieres cifrado independiente del proveedor de almacenamiento, puedes pasar el dump por gpg antes de enviarlo, aunque eso agrega una llave que ahora también tienes que respaldar de forma segura, lo cual es un riesgo en sí mismo que vale la pena pensar antes de agregarlo.
Un backup que nunca has restaurado es una creencia, no un hecho. Los cinco pasos de arriba convierten esa creencia en algo que realmente verificas de forma programada, para que la primera vez que restaures un backup no sea también la primera vez que descubras si funciona.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.