diff --git a/Backups.md b/Backups.md new file mode 100644 index 0000000..993b143 --- /dev/null +++ b/Backups.md @@ -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/`.