Recuperación ante desastres para servicios autoalojados: nuestra estrategia de copias de seguridad
El autoalojamiento le da un control que los servicios gestionados no ofrecen: usted elige el hardware, la red, la ubicación de los datos y la estructura de costos. Pero ese control conlleva una responsabilidad de la que los servicios gestionados se encargan sin que usted tenga que intervenir: cuando algo falla, el problema recae enteramente en usted. En una configuración con un único servidor que ejecuta varios servicios en producción – Supabase, un CRM, un DAM, un servidor Git –, la estrategia de copias de seguridad y recuperación no es una cuestión secundaria. Es lo que distingue un incidente recuperable de una pérdida permanente de datos.
Este artículo documenta la estrategia de copias de seguridad que utilizamos en un entorno autoalojado de producción. Los servicios son un stack de Supabase (con PostgreSQL como almacén principal de datos), un CRM autoalojado (Twenty, que también utiliza PostgreSQL), un servidor Git Gitea y un sistema de gestión de activos digitales. Todos se ejecutan como contenedores Docker en un único VPS de Hetzner. La estrategia debe cubrirlos todos sin generar períodos de carga inaceptables ni dependencias complejas de orquestación.
Por qué un único servidor aumenta el riesgo
Una configuración con un único servidor no tiene redundancia integrada. No hay una réplica en espera, conmutación por error entre varias zonas de disponibilidad ni restauración automática de la base de datos activada por una comprobación de estado. Cuando el servidor falla – por una avería de hardware, una actualización fallida, un problema de almacenamiento o un incidente de seguridad –, la única vía de recuperación es una copia de seguridad. Si está incompleta, desactualizada o no se ha probado, las posibilidades de recuperación quedan limitadas en consecuencia.
Este modelo de amenazas difiere del de un servicio gestionado en la nube, donde los fallos de hardware pasan en gran medida inadvertidos porque el proveedor de infraestructura se encarga de la conmutación por error. En un único VPS, hay que planificar explícitamente los escenarios de fallo que los servicios gestionados ocultan al usuario. La planificación no es especialmente compleja, pero debe hacerse antes del incidente, no durante él.
Qué necesita copias de seguridad
Cada servicio tiene sus propias características de datos. Entender qué datos son irremplazables y dónde se encuentran es un requisito previo para diseñar la estrategia de copias de seguridad.
Supabase almacena sus datos en una instancia de PostgreSQL gestionada por el stack de Supabase. La base de datos contiene todos los datos de la aplicación, los registros de usuarios y los metadatos de autenticación y almacenamiento del propio Supabase. Los buckets de almacenamiento (archivos cargados) se guardan en disco y deben respaldarse por separado de la base de datos.
Twenty CRM utiliza su propia instancia de PostgreSQL, independiente de la de Supabase. Contiene registros de contactos, datos de oportunidades y el estado de los flujos de trabajo. Esta base de datos tiene un volumen menor, pero es muy sensible: perder los datos del CRM altera las operaciones más que perder datos de la aplicación que pueden recrearse.
Gitea almacena los repositorios en disco como objetos de repositorios Git bare. En principio, los datos de los repositorios pueden reconstruirse a partir de las computadoras de los desarrolladores (ya que un clon completo, no superficial, contiene el historial que ha obtenido, aunque no los objetos de Git LFS ni las referencias que nunca ha obtenido), pero los datos del seguimiento de incidencias, los comentarios de las pull requests y la configuración de los equipos solo existen en la base de datos de Gitea y no están presentes en ningún clon. Tanto los objetos Git como la base de datos de Gitea necesitan copias de seguridad.
DAM (gestión de activos digitales) almacena archivos originales, versiones derivadas procesadas y metadatos. Los archivos originales son la parte irremplazable; las versiones derivadas pueden regenerarse. Los metadatos se encuentran en una base de datos que registra las relaciones entre archivos, las etiquetas y los derechos de uso.
Orquestación de pg_dump
La herramienta pg_dump de PostgreSQL es adecuada para crear copias de seguridad lógicas de bases de datos. Exporta una representación SQL de la base de datos que puede restaurarse en cualquier versión compatible de PostgreSQL, lo que ofrece mayor portabilidad que las copias de seguridad físicas (que requieren la misma versión de PostgreSQL y la misma estructura binaria).
El script de copia de seguridad de cada instancia de PostgreSQL sigue el mismo patrón:
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
docker exec "${CONTAINER_NAME}" pg_dump -U postgres -d "${DB_NAME}" | gzip -9 > "${OUTPUT_FILE}"
echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"
La instrucción set -euo pipefail al principio es importante: hace que el script termine inmediatamente si falla algún comando, incluidos los de un pipeline. Sin pipefail, un pg_dump fallido seguido de un gzip correcto dejaría un archivo comprimido vacío o truncado (el mensaje de error se envía a stderr, no al archivo), mientras el script seguiría indicando que la operación se completó correctamente; el problema solo se detectaría al restaurar.
La salida se comprime directamente con gzip -9, en lugar de hacerlo en una etapa posterior. Esto reduce el espacio ocupado en disco y evita escribir primero un volcado sin comprimir, algo importante en servidores donde el espacio libre es limitado y debe gestionarse.
Programación escalonada
Ejecutar simultáneamente todas las tareas de copia de seguridad generaría un período de contención de E/S que podría degradar los servicios que se están respaldando. Una instancia de Supabase bajo carga no se beneficia de competir con pg_dump por las operaciones de E/S del disco. La solución es una programación escalonada: cada tarea comienza a una hora distinta, con suficiente separación para que la anterior termine antes de que empiece la siguiente.
Este es el horario que utilizamos, expresado como entradas de cron:
# Supabase PostgreSQL backup — 02:00 daily
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db
# Twenty CRM PostgreSQL backup — 02:30 daily
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db
# Gitea database backup — 03:00 daily
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db
# DAM database backup — 03:30 daily
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db
# Gitea repository objects — 04:00 daily
0 4 * * * /opt/scripts/git-objects-backup.sh
# Storage files (Supabase + DAM originals) — 04:30 daily
30 4 * * * /opt/scripts/files-backup.sh
Los intervalos de 30 minutos entre tareas son conservadores: la mayoría de las copias de bases de datos de este tamaño se completan en pocos minutos. Pero también contemplan la carga posterior a cada copia: el archivo comprimido se carga en el almacenamiento de objetos antes de que comience la siguiente tarea, de modo que el disco local no acumula simultáneamente copias de varios días.
Carga en almacenamiento de objetos y conservación
Las copias que solo existen en el mismo servidor que los servicios que protegen no son copias de seguridad: son instantáneas que se perderán en el mismo incidente que destruya los datos protegidos. Cada copia debe cargarse en una ubicación de almacenamiento independiente antes de considerar completa la copia local.
Utilizamos un proveedor de almacenamiento de objetos compatible con S3 (distinto del proveedor del VPS) y realizamos la carga con rclone, que gestiona automáticamente los reintentos y la verificación de sumas de comprobación después de la transferencia:
rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/" --checksum --transfers 1 --log-level INFO
La opción --checksum hace que rclone compare los archivos por hash y tamaño (MD5 en destinos remotos compatibles con S3), en lugar de por fecha de modificación y tamaño. Junto con la comprobación de hash que rclone realiza de forma predeterminada después de la transferencia, esto detecta daños durante la transferencia siempre que el destino remoto almacene un hash utilizable.
La conservación se gestiona mediante una tarea de limpieza independiente que se ejecuta semanalmente. La política es la siguiente: las copias diarias se conservan durante siete días; las semanales (la copia diaria del domingo, renombrada por una tarea semanal), durante cuatro semanas; y las mensuales (el primer domingo de cada mes), durante seis meses. Esto ofrece un margen razonable para detectar pérdidas de datos que no resultan evidentes de inmediato: una fila dañada hace tres semanas aún puede recuperarse de una copia semanal.
La limpieza de las copias utiliza rclone delete con un filtro por fecha de modificación (--min-age), en lugar de basarse únicamente en los nombres de archivo, lo que resulta más fiable cuando las convenciones de nomenclatura no son totalmente uniformes:
rclone delete "backup-remote:tva-backups/postgres/" --min-age 7d --include "*_daily_*" --dry-run
La opción --dry-run se utiliza durante las pruebas de la tarea de limpieza. Elimínela solo después de confirmar que los patrones de filtrado coinciden exactamente con lo que debe borrarse. El patrón de --include supone que las copias de seguridad diarias llevan el marcador _daily_ en sus nombres; el script anterior nombra los archivos como DB_TIMESTAMP.sql.gz, por lo que primero debe ajustar el patrón o los nombres de archivo.
Pruebas de restauración
Una copia de seguridad que nunca se ha probado es una hipótesis. La única forma de saber que puede restaurarse es restaurarla. Realizamos una prueba mensual de restauración de cada base de datos mediante un contenedor Docker temporal aislado del stack de producción:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"
# Start a temporary PostgreSQL container
docker run -d --name "${TEST_CONTAINER}" -e POSTGRES_PASSWORD=testpass -e POSTGRES_DB=testdb postgres:15-alpine
# Remove the container on exit, also when a step fails
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT
# Wait for PostgreSQL to be ready
for _ in $(seq 1 30); do
docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
sleep 1
done
# pg_dump omits roles: restore them first from a pg_dumpall --roles-only file (optional 2nd argument)
if [ -n "${ROLES_FILE}" ]; then
docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi
# Restore the backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}" psql -U postgres -d testdb -v ON_ERROR_STOP=1
# Verify row counts match expectations
docker exec "${TEST_CONTAINER}" psql -U postgres -d testdb -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
La verificación del número de filas no es exhaustiva: comprueba que las tablas principales tengan cantidades estimadas de filas plausibles (n_live_tup es una estimación estadística, no un recuento exacto), no que cada fila sea correcta. Una prueba más completa ejecutaría las comprobaciones de estado de la propia aplicación contra la base de datos restaurada, pero la verificación del número de filas detecta los fallos más habituales: una copia truncada, una restauración fallida que produjo una base de datos vacía o una incompatibilidad de versiones que causó una pérdida silenciosa de datos. La restauración se ejecuta con ON_ERROR_STOP=1, de modo que la primera sentencia fallida hace fallar la prueba, y el trap elimina el contenedor en cualquier caso. pg_dump no incluye los roles: si el volcado hace referencia a roles distintos de postgres, pase como segundo argumento la salida de pg_dumpall --roles-only del servidor de origen (en ese paso es esperable un error por el rol postgres, que ya existe). La imagen de prueba debe coincidir con la versión principal de PostgreSQL del origen. El volcado de Supabase además hace referencia a esquemas y extensiones propios de Supabase que la imagen postgres estándar no tiene, por lo que debe restaurarse para la prueba en una instancia de Supabase siguiendo el procedimiento de restauración de Supabase.
Cómo se realiza la recuperación
La estrategia de copias de seguridad solo es tan útil como el procedimiento de recuperación que las utiliza. El manual de recuperación, escrito como un documento Markdown en el repositorio de configuración del servidor, recoge la secuencia exacta de pasos para restaurar cada servicio en un VPS nuevo.
La secuencia ante un fallo completo del servidor es: aprovisionar un VPS nuevo, instalar Docker y el stack de aplicaciones desde el repositorio de configuración, descargar las copias más recientes del almacenamiento de objetos, restaurar cada base de datos según el orden de dependencias (primero Supabase, luego el CRM y después las demás), restaurar los archivos almacenados, actualizar el DNS y verificar cada servicio. El tiempo estimado para una recuperación completa es inferior a dos horas si todas las copias están actualizadas y se sigue el manual sin improvisar.
La expresión clave de esa estimación es «seguir el manual sin improvisar». El manual debe ser lo bastante específico para que una persona que nunca haya trabajado con el sistema pueda ejecutarlo correctamente. Los comandos deben poder copiarse y pegarse, no describirse en prosa. Cada paso debe incluir una comprobación. La ambigüedad en un manual de recuperación es un riesgo que se agrava bajo el estrés de un incidente real.
Probamos el manual anualmente mediante una recuperación completa en un entorno de preproducción. La prueba siempre revela al menos un paso que ha cambiado desde la última redacción del manual: una versión de imagen Docker que cambió, una clave de configuración renombrada o una variable de entorno añadida. Detectar estos cambios en una prueba programada es mucho mejor que descubrirlos durante una recuperación real.
Artículos relacionados
- Una auditoría de seguridad en varias fases: refuerzo de la seguridad de una aplicación Next.js – La capa de refuerzo de la seguridad que complementa la estrategia de copias de seguridad al reducir la probabilidad de los incidentes que hacen necesaria la recuperación.
- Desarrollo de una app de preparación de exámenes con miles de preguntas – Supabase como backend de producción, incluidas las decisiones sobre RLS y diseño de esquemas que determinan qué debe contener una copia de seguridad.