Sistema de backups del servidor (capa Coolify + pull a disco local)
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Backups del servidor Hetzner
|
||||||
|
|
||||||
|
Montado el 2026-08-01. Cubre feadulta, summaraise, los dos CRM (Relaticle), gitea y el
|
||||||
|
propio Coolify.
|
||||||
|
|
||||||
|
## Diseño en dos capas
|
||||||
|
|
||||||
|
**Capa 1 — bases de datos: feature estándar de Coolify.** Scheduled Database Backups, solo a
|
||||||
|
disco local del servidor, retención 7 copias. Sin S3.
|
||||||
|
|
||||||
|
**Capa 2 — traslado a disco local: `pull-hetzner.sh` en la WSL.** Un rsync sobre Tailscale que
|
||||||
|
se trae los dumps y, además, los volúmenes de ficheros que **Coolify no sabe backupear**
|
||||||
|
(es una carencia conocida del producto: solo hace backup de bases de datos, no de volúmenes
|
||||||
|
persistentes).
|
||||||
|
|
||||||
|
### Por qué PULL y no push
|
||||||
|
|
||||||
|
Lo lanza siempre la máquina local, nunca el servidor. Dos motivos:
|
||||||
|
|
||||||
|
1. **El PC no está siempre encendido.** Con push, cada ventana con el PC apagado sería un
|
||||||
|
backup fallido con alerta. Con pull, la siguiente pasada recupera sola (y el timer usa
|
||||||
|
`Persistent=true`, así que se ejecuta al arrancar si se saltó la hora).
|
||||||
|
2. **El servidor no tiene ninguna credencial de escritura sobre el destino.** feadulta ya fue
|
||||||
|
comprometida una vez (incidente #183). Si vuelve a pasar, un atacante con root en Hetzner
|
||||||
|
no tiene camino hacia estas copias.
|
||||||
|
|
||||||
|
### Sin `--delete`, a propósito
|
||||||
|
|
||||||
|
El destino es aditivo. Un borrado en el servidor —accidental o malicioso— no se propaga al
|
||||||
|
backup. Es lo que separa una copia de una réplica. Consecuencia esperada: el destino tiene
|
||||||
|
a veces **más** ficheros que el origen, y eso NO es un fallo. La verificación solo salta si
|
||||||
|
faltan ficheros.
|
||||||
|
|
||||||
|
## Qué se copia
|
||||||
|
|
||||||
|
| Origen (srv1) | Destino en `N:\Backup\hetzner\` |
|
||||||
|
|---|---|
|
||||||
|
| `/data/coolify/backups/` (dumps de capa 1, incluido el de la BD de Coolify) | `dumps-coolify/` |
|
||||||
|
| volumen `r2ssjifwj0r0ghyoqd528uaa_wordpress-files` | `feadulta-wp/wordpress-files/` |
|
||||||
|
| volumen `pgjls5t2h3t1jmda6ht8tu6e_wordpress-files` | `summaraise/wordpress-files/` |
|
||||||
|
| volumen `fr9v89w8g1nsyrc4718du5r7_wordpress-files` | `aqtalent-web/wordpress-files/` |
|
||||||
|
| volumen `krd5enbr8iafux0rx3clc65w_gitea-data` | `gitea/gitea-data/` |
|
||||||
|
| volumen `hqt9vm56ijhfcsxhais61yge_storage` | `triptyk-crm/storage/` |
|
||||||
|
| `/data/coolify/ssh/` y `/data/coolify/proxy/` | `coolify-config/` |
|
||||||
|
|
||||||
|
**Fuera del backup a propósito:** `/data/feadulta-antiguo` (4,9 GB del mirror estático) es
|
||||||
|
regenerable desde la WSL con `80-sync-hetzner.sh`. El `redis` del CRM es caché y Coolify no lo
|
||||||
|
soporta.
|
||||||
|
|
||||||
|
Tamaño total: **7,0 GB**. Primera pasada 20 min; las incrementales, 4 min.
|
||||||
|
|
||||||
|
## Programación
|
||||||
|
|
||||||
|
- **Dumps (Coolify):** los crons se interpretan en la zona del servidor, que está en **UTC**
|
||||||
|
(el campo `Timezone` del formulario está `disabled`, es informativo). summaraise 02:15,
|
||||||
|
feadulta 04:00, aqtalent 04:30, CRM 04:45, gitea 05:00, BD de Coolify 05:15 — todo UTC.
|
||||||
|
- **Pull:** timer de systemd `--user` en la WSL, `02,08,14,20` hora de Nueva York
|
||||||
|
= `06,12,18,00` UTC. La pasada de las 06:00 UTC recoge todos los dumps de la madrugada.
|
||||||
|
- **Alerta:** `OnFailure=` → `avisa-telegram.sh`, que lee las credenciales de
|
||||||
|
`~/.hermes/.env` en el momento de usarlas.
|
||||||
|
|
||||||
|
## Verificado el 2026-08-01
|
||||||
|
|
||||||
|
Restauración real del dump de feadulta en un MySQL 8 desechable, comparada contra producción:
|
||||||
|
43.947 posts / 29.486 publicados / 986 options / 1.228 usuarios / 135.928 postmeta / 54 tablas,
|
||||||
|
**idéntico en ambos lados**. Import en 94 s. Acentos correctos, 0 mojibake, y
|
||||||
|
`wordfence_core_options` (el payload del atacante del #183) ausente del dump.
|
||||||
|
|
||||||
|
## Trampas encontradas — no volver a descubrirlas
|
||||||
|
|
||||||
|
**NTFS no distingue mayúsculas y se come ficheros en silencio.** En
|
||||||
|
`uploads/Musica/` conviven `aleluya.mp3` y `Aleluya.mp3`. En el primer intento faltaban 18
|
||||||
|
ficheros en el destino **y rsync salió con código 0 sin una sola queja**. De esos 18 pares, 4
|
||||||
|
eran ficheros realmente distintos. Arreglado con:
|
||||||
|
|
||||||
|
fsutil file setCaseSensitiveInfo "N:\Backup\hetzner" enable
|
||||||
|
|
||||||
|
(requiere PowerShell **como administrador**). El atributo lo heredan los subdirectorios que se
|
||||||
|
creen después, así que hay que ponerlo **con la carpeta vacía** y luego copiar. Verificar que
|
||||||
|
funciona creando `Foo.txt` y `foo.txt` en un subdirectorio anidado nuevo, no fiarse del
|
||||||
|
mensaje del comando.
|
||||||
|
|
||||||
|
**Los flags de rsync importan sobre DrvFs.** Hay que usar `-rlt --modify-window=1` y **no**
|
||||||
|
`-a`: NTFS no guarda permisos POSIX, así que con `-a` rsync cree que todo cambió y recopia los
|
||||||
|
7 GB en cada pasada. Con estos flags, la segunda pasada transfiere 0 ficheros.
|
||||||
|
|
||||||
|
**Coolify no ofrece "Backup All Databases" para bases de datos dentro de un servicio.** El
|
||||||
|
bloque de la vista solo se renderiza para `StandaloneMysql` / `StandalonePostgresql` /
|
||||||
|
`StandaloneMariadb`. Consecuencia: los dumps de MySQL/MariaDB van **sin comprimir** y
|
||||||
|
`mysqldump` corre **sin `--single-transaction`**, o sea bloqueando tablas mientras vuelca
|
||||||
|
(segundos, de madrugada). Postgres no está afectado: usa `pg_dump --format=custom`, ya
|
||||||
|
comprimido.
|
||||||
|
|
||||||
|
**Retención 0/0/0 significa "no borrar nunca".** Verificado en
|
||||||
|
`bootstrap/helpers/databases.php`: si los tres criterios están a cero, la función devuelve una
|
||||||
|
colección vacía y no se limpia nada.
|
||||||
|
|
||||||
|
**La API de Coolify no sirve para esto.** `/api/v1/databases` devuelve `[]` y
|
||||||
|
`GET /databases/{uuid}/backups` con el uuid de una BD de servicio responde `Database not
|
||||||
|
found`: solo maneja bases de datos standalone. Los backups hay que crearlos en el panel, igual
|
||||||
|
que pasa con el FQDN de un servicio.
|
||||||
|
|
||||||
|
**El aviso rojo "No validated S3 Storages found." no bloquea.** Es informativo; `saveToS3` es
|
||||||
|
`false` por defecto y la validación solo exige `frequency`.
|
||||||
|
|
||||||
|
**`mysqladmin ping` miente durante la inicialización de MySQL.** Responde "alive" incluso con
|
||||||
|
Access denied mientras el entrypoint todavía está arrancando. Para esperar a que un MySQL esté
|
||||||
|
listo de verdad hay que hacer una consulta real (`select 1`).
|
||||||
|
|
||||||
|
## Pendiente
|
||||||
|
|
||||||
|
- **No es 3-2-1**: hay copia en el servidor y en casa, pero nada fuera de casa. Los dumps de BD
|
||||||
|
pesan poco (~240 MB); un tercer destino barato solo para ellos sería el siguiente paso.
|
||||||
|
- **Rotación local**: el destino crece sin límite (los `.dmp` son ficheros nuevos cada día).
|
||||||
|
Con 132 GB libres en N: hay margen de sobra para un buen tiempo, pero conviene una poda.
|
||||||
|
- Los dumps de MySQL en N: se podrían comprimir tras la copia (~80% de ahorro).
|
||||||
|
|
||||||
|
## Ficheros
|
||||||
|
|
||||||
|
Todo en `~/backup-hetzner/` de la WSL: `pull-hetzner.sh`, `avisa-telegram.sh`, `lib.sh` y las
|
||||||
|
unidades de systemd en `systemd/`.
|
||||||
Reference in New Issue
Block a user