Cómo reemplazamos un backup basado en nextcloudcmd por restic + rclone
para respaldar varios terabytes de carpetas corporativas hacia Nextcloud, en un servidor Linux sin
entorno gráfico.
Destino: Nextcloud remoto (WebDAV)
Volumen: ~4 TB / 25 carpetas
Este artículo documenta el razonamiento técnico y la implementación completa del sistema de backup
que usamos hoy en CAB Group SRL para respaldar carpetas Samba corporativas hacia un servidor Nextcloud
ubicado en otra sede — desde por qué descartamos el cliente oficial de línea de comandos de Nextcloud,
hasta cómo se restaura un archivo puntual sin tocar el resto del repositorio.
Necesitábamos mantener una copia de seguridad externa de nuestras carpetas compartidas por Samba
(varios terabytes distribuidos en más de 20 carpetas) en un servidor Nextcloud alojado en otra
ubicación física. La restricción principal: el servidor origen es un Linux sin entorno
gráfico, así que no existe la posibilidad de dejar corriendo un cliente Nextcloud de
escritorio en segundo plano, como el que cualquiera instala en una PC con Windows o Linux con interfaz.
La única herramienta oficial disponible en consola es nextcloudcmd, el cliente de línea
de comandos de Nextcloud. Nuestro primer enfoque lo disparaba por cron: copiaba cada carpeta a un
directorio temporal, normalizaba los nombres de archivo problemáticos para WebDAV, y sincronizaba
contra el remoto con reintentos manuales. Funcionaba, pero arrastraba tres problemas estructurales
que terminaron definiendo el cambio de herramienta.
El problema de fondo. nextcloudcmd está pensado para dos escenarios:
correr como proceso residente con entorno gráfico, manteniendo un archivo de estado persistente entre
corridas (el journal de sincronización), o para una sincronización bidireccional puntual.
Ninguno de los dos aplica a un servidor headless que dispara un backup una vez por día vía cron.
- Sin entorno gráfico, la única forma viable de usarlo es invocarlo desde cron, corrida completa por vez.
- Sin estado persistente entre corridas: si el directorio temporal se arma y se borra en cada ejecución, no queda journal — cada corrida tiene que re-comparar el árbol completo contra el remoto para saber qué subir.
- Sin dry-run confiable ni logging estructurado, dificulta saber de antemano qué va a pasar antes de tocar datos de producción.
La solución: restic (backup) + rclone (transporte). restic es una herramienta de
backup diseñada específicamente para correr por cron en servidores sin interfaz: se ejecuta, hace su
trabajo, termina — no necesita quedar residente en memoria. rclone actúa como transporte hacia el
WebDAV de Nextcloud; restic no necesita saber nada específico de Nextcloud, solo delega el «cómo
llegar» a rclone.
- Corre por cron de forma nativa — exactamente el modelo para el que fue construido.
- Versionado real, no un espejo: cada corrida genera un snapshot inmutable identificado por ID, restaurable de forma independiente.
- Credenciales fuera del comando: usuario y clave quedan en un archivo de entorno con permisos restringidos, no expuestos en la línea de comandos.
Soluciona el problema de caracteres raros sin normalizar nada. El script original
necesitaba una función completa para reemplazar caracteres prohibidos en WebDAV/Windows
(\ : * ? " < > | # % & { } ~ `) antes de subir cualquier archivo, porque el
destino final era una estructura de carpetas y archivos reales, con nombres que tenían que ser válidos
para ese sistema de archivos remoto.
restic no funciona así: guarda el contenido como bloques cifrados direccionados por
hash (SHA-256) dentro de su propio repositorio. El nombre original del archivo queda como
metadato interno, no como un nombre de archivo real en el sistema remoto — Nextcloud nunca ve una ruta
con caracteres raros que tenga que validar. La función de normalización de nombres dejó de ser
necesaria.
Es más rápido en volúmenes grandes. La diferencia está en el nivel de granularidad
de la comparación:
nextcloudcmd/rsynccomparan a nivel de archivo completo (tamaño + fecha de modificación). Si un archivo de 2 GB cambia en un byte, se retransfiere entero.- restic divide cada archivo en bloques de tamaño variable y compara a ese nivel. Si un archivo de 2 GB tiene 10 MB modificados, sube solo esos 10 MB.
En producción, la segunda corrida sobre una de nuestras carpetas más grandes (7.4 GB, más de
35.000 archivos) recorrió y comparó todo el árbol en menos de 2 minutos, subiendo
0 bytes porque nada había cambiado — contra los más de 25 minutos que tomó la primera
carga completa.
Servidor origen (Linux headless) Nextcloud remoto (cloud.****.com.ar) ┌───────────────────────────┐ ┌───────────────────────────┐ │ /srv/****/ (~4 TB) │ │ /****/ │ │ ├── carpeta-01 │ │ └── restic-backup/ │ │ ├── carpeta-02 │ │ (repositorio │ │ ├── ... (25 carpetas) │ HTTPS │ cifrado) │ │ │─────────▶│ │ │ restic ──▶ rclone │ WebDAV │ │ │ (dedup + cifrado local) │ │ │ └───────────────────────────┘ └───────────────────────────┘
restic lee el origen, chunkea y cifra localmente; rclone se encarga solo del transporte WebDAV.
| Componente | Rol |
|---|---|
restic |
Motor de backup: deduplicación por bloques, cifrado, versionado por snapshots, retención. |
rclone |
Transporte: habla WebDAV contra Nextcloud. restic lo usa como backend, no como sincronizador. |
| Remote configurado | Credenciales de Nextcloud guardadas en la configuración de rclone. |
| Repositorio restic | Carpeta remota donde vive el repositorio cifrado completo, con todos los snapshots. |
| Archivo de entorno | Variables con la ubicación del repositorio y la clave de cifrado. |
Control de instancia única. Al arrancar, el script verifica si existe un archivo
de lock con el PID de una corrida anterior. Si ese proceso sigue vivo, sale sin hacer nada (evita
corridas superpuestas). Si el PID ya no existe — por ejemplo tras un corte manual — limpia el lock
huérfano y continúa normalmente.
Detección automática de carpetas. En cada corrida, el script escanea el directorio
base y toma todas las subcarpetas de primer nivel que encuentra, salvo las excluidas explícitamente.
Si mañana se crea una carpeta nueva, la próxima corrida la incorpora sola — no hace falta tocar el
script.
Un snapshot por carpeta, mismo repositorio. Cada carpeta se respalda con su propio
comando y su propio tag, pero todas comparten el mismo repositorio restic. Esto permite que la
deduplicación funcione también entre carpetas, y que la retención se aplique de forma
independiente para cada una.
Reintentos ante fallos. Si una carpeta falla, el script reintenta un par de veces
con varios minutos de espera entre intentos. Ese margen es intencional: si un intento anterior fue
cortado a mitad de una escritura, Nextcloud puede quedar con un bloqueo temporal sobre ese objeto del
lado servidor. Reintentar de inmediato choca contra ese lock todavía activo — esperar le da tiempo a
Nextcloud de liberarlo solo.
Por qué se desactivó el timeout en la primera carga completa. En las primeras
pruebas fijamos un límite de tiempo por carpeta. Con carpetas de gran volumen y un enlace de subida
limitado, ese límite resultó matemáticamente insuficiente: transferir varios terabytes puede tomar más
de un día entero, solo de transferencia pura. El timeout cortaba el proceso a la fuerza a mitad de una
subida legítima — y eso, además, dejaba locks huérfanos en Nextcloud que disparaban en cascada errores
de bloqueo (423 Locked) y del backend de sesiones (500 Internal Server Error).
# 0 = sin límite de tiempo por carpeta.
# Correcto SOLO para la primera carga completa con volúmenes grandes.
BACKUP_TIMEOUT=0
Este valor es correcto solo para la primera carga completa. Una vez que todas las
carpetas tienen un snapshot base, las corridas siguientes son incrementales y deberían tardar minutos,
no horas — ahí sí conviene volver a fijar un timeout acotado como red de seguridad: si una corrida
incremental tarda eso, algo anómalo está pasando y vale más que el script lo corte y avise.
Límite de conexiones concurrentes. Nextcloud usa Redis para su sistema de bloqueo
transaccional de archivos. Con muchas conexiones simultáneas escribiendo bloques, ese subsistema queda
bajo presión y empieza a rechazar operaciones. Limitar la concurrencia del lado del cliente reduce esa
presión, a costa de una subida algo más lenta — un trade-off razonable para priorizar que el backup
complete sin errores en cascada.
restic backup /srv/****/carpeta-ejemplo \
--tag carpeta-ejemplo \
-o rclone.connections=2
restic no guarda un snapshot por corrida para siempre — eso haría crecer el espacio usado sin
límite. Aplica en cambio una política de poda que conserva más densidad de puntos de
recuperación cerca del presente, y menos a medida que el historial se aleja en el tiempo.
| Regla | Qué conserva |
|---|---|
--keep-daily 7 |
Un snapshot por cada uno de los últimos 7 días. |
--keep-weekly 4 |
De las 4 semanas anteriores, un snapshot por semana. |
--keep-monthly 6 |
De los 6 meses anteriores a eso, un snapshot por mes. |
--prune |
Elimina físicamente del repositorio los datos que ya no pertenecen a ningún snapshot conservado. |
En la práctica: corriendo todos los días durante un año, no se acumulan 365 snapshots — quedan como
máximo ~17 (7+4+6), distribuidos con grano fino en lo reciente y grano grueso en lo
histórico. La poda se aplica de forma independiente por carpeta, así que una que cambia todos los días
y otra que casi no cambia nunca se podan cada una según su propio historial.
En los tres casos, primero se cargan las credenciales del repositorio en la sesión de terminal.
Ver los snapshots disponibles.
# Todos los snapshots del repositorio
restic snapshots
# Solo los snapshots de una carpeta puntual
restic snapshots --tag carpeta-ejemplo
La salida incluye el ID corto de cada snapshot, la fecha y el tag — esos tres datos son los que se usan para restaurar.
Restauración completa (el 100% del repositorio). Trae de vuelta el estado completo del último snapshot de todas las carpetas:
mkdir -p /srv/restore-completo
restic restore latest --target /srv/restore-completo
Para restaurar el estado de una fecha específica en vez del más reciente, se reemplaza
latest por el ID del snapshot correspondiente.
Restauración selectiva por carpeta. Como cada carpeta tiene su propio tag, se puede restaurar solo una sin tocar el resto:
mkdir -p /srv/restore-carpeta-ejemplo
restic restore latest --tag carpeta-ejemplo --target /srv/restore-carpeta-ejemplo
Restauración de un único archivo. Dos formas, según convenga reconstruir la ruta completa o solo obtener el archivo suelto:
Opción A — a un directorio, manteniendo la ruta original:
restic restore latest --tag carpeta-ejemplo \
--target /tmp/restore-archivo \
--include "/srv/****/carpeta-ejemplo/documento.xlsx"
# Queda en: /tmp/restore-archivo/srv/****/carpeta-ejemplo/documento.xlsx
Opción B — directo a un archivo, sin reconstruir subcarpetas:
restic dump latest --tag carpeta-ejemplo \
/srv/****/carpeta-ejemplo/documento.xlsx > /tmp/documento.xlsx
La opción B es la más práctica cuando solo hace falta ese archivo puntual, sin todo el árbol de carpetas que lo contiene.
Restaurar hacia una ruta nueva (como en todos los ejemplos de arriba) es siempre seguro. Restaurar
directamente encima de la carpeta original sobrescribe el contenido actual — conviene verificar
primero en una ruta temporal antes de reemplazar datos en vivo.
Antes de instalar en cron:
- Confirmar que la primera carga completa terminó sin carpetas en fallo total.
- Revisar el resumen final del log: exitosas / fallidas / omitidas.
- Volver a fijar un timeout acotado, ya que las corridas de cron en adelante van a ser incrementales.
- Validar al menos una restauración de prueba antes de dar el proceso por confiable.
Sin la contraseña de cifrado del repositorio no hay forma de recuperar el backup — ni Nextcloud ni
nadie puede rescatar los datos si se pierde. Tiene que estar guardada también en un gestor de
contraseñas, no solo en el servidor.
- Correr backup manual
bash restic-backup.sh- Ver snapshots
restic snapshots- Ver snapshots de una carpeta
restic snapshots --tag <carpeta>- Restaurar todo
restic restore latest --target <destino>- Restaurar una carpeta
restic restore latest --tag <carpeta> --target <destino>- Restaurar un archivo
restic dump latest --tag <carpeta> <ruta> > <destino>- Verificar integridad
restic check- Ver log del día
tail -f backup-$(date +%Y%m%d).log
Versión completa del script, saneada de nombres de host y rutas reales — lista para adaptar
y reutilizar. Todas las rutas usan valores genéricos de ejemplo; los comentarios explican
cada bloque para que se entienda sin haber seguido el resto del artículo.
restic-backup.sh
#!/bin/bash
# ============================================================
# restic-backup.sh
#
# Backup incremental con deduplicación de un árbol de carpetas
# hacia un backend remoto (Nextcloud u otro WebDAV) usando
# restic + rclone.
#
# QUÉ HACE:
# - Detecta automáticamente las subcarpetas de primer nivel
# dentro de BACKUP_SOURCE (no hace falta listarlas a mano).
# - Genera un snapshot restic por carpeta, todos dentro del
# mismo repositorio (permite deduplicación también entre
# carpetas y retención independiente por carpeta).
# - Reintenta ante fallos transitorios, con espera suficiente
# para que el backend remoto libere locks huérfanos.
# - Aplica una política de retención (poda automática) al
# final de cada corrida completa.
# - Corre una verificación de integridad semanal (domingos).
#
# REQUISITOS:
# - restic y rclone instalados.
# - Un remote de rclone ya configurado (rclone config) apuntando
# al backend WebDAV/S3/etc. de destino.
# - Un archivo de entorno con RESTIC_REPOSITORY y RESTIC_PASSWORD
# (ver samba.env.example en este mismo repositorio de ejemplo).
#
# USO:
# bash restic-backup.sh
# (pensado para disparar por cron una vez que la primera carga
# completa haya terminado sin errores)
# ============================================================
set -o pipefail
# ------------------------------------------------------------
# CONFIGURACIÓN — ajustar estas rutas a tu entorno
# ------------------------------------------------------------
BACKUP_SOURCE="/srv/datos" # carpeta base a respaldar
EXCLUDE_DIRS=(".nc-staging" "lost+found") # subcarpetas a ignorar dentro de BACKUP_SOURCE
RESTIC_ENV="/etc/scripts/backup/repo.env" # acá van RESTIC_REPOSITORY y RESTIC_PASSWORD
LOG_DIR="/var/log/restic-backup"
LOG_FILE="${LOG_DIR}/backup-$(date +%Y%m%d).log"
LOCK_FILE="/tmp/restic-backup.lock"
MAX_RETRIES=2
RETRY_WAIT=300 # segundos entre reintentos (5 min - le da tiempo al backend remoto de liberar locks huérfanos)
BACKUP_TIMEOUT=0 # segundos por carpeta. 0 = SIN LÍMITE.
# Usar 0 en la primera carga completa con carpetas grandes / enlace lento.
# Una vez que todas las carpetas tengan snapshot base, fijar un valor
# acotado (ej. 10800 = 3hs) como red de seguridad para las corridas
# incrementales — si una corrida incremental tarda eso, algo anómalo
# está pasando y conviene que el script lo corte y avise.
RETENTION_ARGS="--keep-daily 7 --keep-weekly 4 --keep-monthly 6"
RCLONE_CONNECTIONS=2 # límite de conexiones concurrentes hacia el backend remoto.
# Reduce la presión sobre sistemas de locking basados en Redis
# (típico en Nextcloud) a costa de una subida algo más lenta.
BACKUP_HOST_TAG="$(hostname)" # identificador de host que queda grabado en cada snapshot
# ------------------------------------------------------------
# LOG
# ------------------------------------------------------------
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}"
}
log_separator() {
echo "============================================================" >> "${LOG_FILE}"
}
# ------------------------------------------------------------
# CLEANUP
# ------------------------------------------------------------
cleanup() {
rm -f "${LOCK_FILE}"
log "Script finalizado. Lock liberado."
}
# ------------------------------------------------------------
# INSTANCIA ÚNICA
# Evita que dos ejecuciones se solapen si una corrida anterior
# todavía no terminó (por ejemplo si tarda más que el intervalo
# de cron configurado).
# ------------------------------------------------------------
mkdir -p "${LOG_DIR}"
if [ -f "${LOCK_FILE}" ]; then
PID=$(cat "${LOCK_FILE}")
if kill -0 "${PID}" 2>/dev/null; then
log "AVISO: Backup en curso (PID ${PID}). Saliendo sin hacer nada."
exit 0
else
log "Lock huérfano (PID ${PID} ya no existe). Continuando."
rm -f "${LOCK_FILE}"
fi
fi
echo $$ > "${LOCK_FILE}"
trap cleanup EXIT
# ------------------------------------------------------------
# DEPENDENCIAS
# ------------------------------------------------------------
for cmd in restic rclone; do
if ! command -v "$cmd" &>/dev/null; then
log "ERROR: comando requerido no encontrado: $cmd"
exit 1
fi
done
if [ ! -f "${RESTIC_ENV}" ]; then
log "ERROR: no se encuentra ${RESTIC_ENV} con las credenciales del repo"
exit 1
fi
# shellcheck disable=SC1090
source "${RESTIC_ENV}"
if [ -z "${RESTIC_REPOSITORY}" ] || [ -z "${RESTIC_PASSWORD}" ]; then
log "ERROR: RESTIC_REPOSITORY o RESTIC_PASSWORD no están seteados"
exit 1
fi
export RESTIC_REPOSITORY
export RESTIC_PASSWORD
# ------------------------------------------------------------
# DETECCIÓN AUTOMÁTICA DE CARPETAS
# Toma todas las subcarpetas de primer nivel de BACKUP_SOURCE,
# salvo las listadas en EXCLUDE_DIRS. Si mañana se crea una
# carpeta nueva, la próxima corrida la incorpora sola.
# ------------------------------------------------------------
detect_folders() {
local folder
while IFS= read -r -d '' folder; do
local name
name=$(basename "${folder}")
local skip=false
for excl in "${EXCLUDE_DIRS[@]}"; do
[ "${name}" = "${excl}" ] && skip=true && break
done
[ "${skip}" = true ] && continue
echo "${name}"
done < <(find "${BACKUP_SOURCE}" -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
}
# ------------------------------------------------------------
# BACKUP DE UNA CARPETA CON REINTENTOS
# ------------------------------------------------------------
backup_folder() {
local folder="$1"
local local_path="${BACKUP_SOURCE}/${folder}"
local attempt=0
local success=false
if [ ! -d "${local_path}" ]; then
log " [SKIP] ${folder}: no existe (${local_path})"
return 2
fi
while [ $attempt -lt $MAX_RETRIES ]; do
attempt=$((attempt + 1))
log " [INTENTO ${attempt}/${MAX_RETRIES}] Backup: ${folder}"
if [ "${BACKUP_TIMEOUT}" -eq 0 ]; then
restic backup "${local_path}" \
--tag "${folder}" \
--host "${BACKUP_HOST_TAG}" \
-o rclone.connections=${RCLONE_CONNECTIONS} \
>> "${LOG_FILE}" 2>&1
else
timeout ${BACKUP_TIMEOUT} restic backup "${local_path}" \
--tag "${folder}" \
--host "${BACKUP_HOST_TAG}" \
-o rclone.connections=${RCLONE_CONNECTIONS} \
>> "${LOG_FILE}" 2>&1
fi
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log " [OK] ${folder}: backup completado"
success=true
break
elif [ $exit_code -eq 124 ]; then
log " [TIMEOUT] ${folder}: superó ${BACKUP_TIMEOUT}s en intento ${attempt}"
else
log " [ERROR] ${folder}: falló con código ${exit_code} en intento ${attempt}"
fi
if [ $attempt -lt $MAX_RETRIES ]; then
log " [ESPERA] Reintentando en ${RETRY_WAIT} segundos..."
sleep ${RETRY_WAIT}
fi
done
if [ "$success" = false ]; then
log " [FALLO TOTAL] ${folder}: agotados ${MAX_RETRIES} intentos"
return 1
fi
return 0
}
# ------------------------------------------------------------
# EJECUCIÓN
# ------------------------------------------------------------
log_separator
log "INICIO DE BACKUP"
log "Repo : ${RESTIC_REPOSITORY}"
log "Base : ${BACKUP_SOURCE}"
log_separator
mapfile -t FOLDERS < <(detect_folders)
log "Carpetas detectadas: ${#FOLDERS[@]}"
for f in "${FOLDERS[@]}"; do
log " - ${f}"
done
log_separator
SUCCESS=0
FAILED=0
SKIPPED=0
for folder in "${FOLDERS[@]}"; do
log ""
log ">> Procesando carpeta: ${folder}"
backup_folder "${folder}"
result=$?
case $result in
0) SUCCESS=$((SUCCESS + 1)) ;;
1) FAILED=$((FAILED + 1)) ;;
2) SKIPPED=$((SKIPPED + 1)) ;;
esac
done
# ------------------------------------------------------------
# RETENCIÓN
# --group-by paths hace que cada carpeta se pode de forma
# independiente, según su propio historial de snapshots.
# ------------------------------------------------------------
log ""
log_separator
log "APLICANDO POLÍTICA DE RETENCIÓN"
log " ${RETENTION_ARGS}"
restic forget --group-by paths ${RETENTION_ARGS} --prune >> "${LOG_FILE}" 2>&1
if [ $? -eq 0 ]; then
log " [OK] Retención aplicada correctamente"
else
log " [ERROR] Falló restic forget/prune - revisar log"
fi
# ------------------------------------------------------------
# VERIFICACIÓN LIGERA DEL REPO (chequea estructura, no baja todo)
# Se corre una vez por semana (domingo) para no sumar carga diaria.
# ------------------------------------------------------------
if [ "$(date +%u)" -eq 7 ]; then
log ""
log "Verificación semanal del repositorio (check)..."
restic check >> "${LOG_FILE}" 2>&1
if [ $? -eq 0 ]; then
log " [OK] Repositorio íntegro"
else
log " [ERROR] Problemas de integridad detectados - revisar log"
fi
fi
# Rotar logs viejos (mantener últimos 30 días)
find "${LOG_DIR}" -name "backup-*.log" -mtime +30 -delete
log ""
log_separator
log "RESUMEN FINAL"
log " Exitosas : ${SUCCESS}"
log " Fallidas : ${FAILED}"
log " Omitidas : ${SKIPPED}"
log_separator
[ $FAILED -eq 0 ] && exit 0 || exit 1
repo.env.example
# ============================================================
# repo.env.example
#
# Plantilla del archivo de entorno que necesita restic-backup.sh.
# Copiar como "repo.env" (o el nombre que apunte RESTIC_ENV en el
# script), completar con los valores reales, y restringir permisos:
#
# cp repo.env.example repo.env
# chmod 600 repo.env
# chown root:root repo.env
#
# No versionar el archivo real (con los valores completos) en
# ningún repositorio ni compartirlo — RESTIC_PASSWORD es la clave
# de cifrado del backup completo. Sin ella el backup es
# irrecuperable, no hay forma de rescatar los datos.
# ============================================================
# Ubicación del repositorio restic. El prefijo "rclone:" le dice a
# restic que use rclone como transporte; lo que sigue es
# "<nombre-del-remote-en-rclone>:<carpeta-remota>".
# El remote ("NOMBRE_REMOTE" acá) se configura antes con:
# rclone config
export RESTIC_REPOSITORY="rclone:NOMBRE_REMOTE:CARPETA-REMOTA/restic-backup"
# Clave de cifrado del repositorio. Generar una fuerte, por ejemplo:
# openssl rand -base64 32
# Guardarla también en un gestor de contraseñas aparte del servidor.
export RESTIC_PASSWORD="REEMPLAZAR-POR-UNA-CLAVE-FUERTE"
Ajustar
BACKUP_SOURCE, RESTIC_ENV, LOG_DIR y el nombre delremote en
repo.env a la infraestructura real. Los valores de este listado songenéricos a propósito.