Qué vas a conseguir
Las bases de datos de las aplicaciones internas (el CRM en MariaDB, el ERP en PostgreSQL) con cuentas separadas y permisos mínimos, una copia lógica cada noche que falla de forma visible si algo va mal, una restauración probada en otra base de datos con su tiempo apuntado, el retraso de la réplica a la vista y la rutina para el ticket que nadie quiere: la copia lleva una semana generando ficheros de 0 bytes.
Antes de empezar
- MySQL 8.0 u 8.4, o MariaDB 10.11 u 11.4, y PostgreSQL 15 o posterior, en Linux, con
sudoen el servidor. - Un destino para las copias que no sea el disco de datos (
/backup, montado desde otra cabina o un NAS) y que luego recoja el sistema de copias (Veeam, cinta). - Un servidor de pruebas (
srv-db-test) con las mismas versiones de los motores para restaurar. - La guía de PostgreSQL, MySQL y SQL Server cubre las consultas básicas, el espacio y la consulta lenta; esta va de cuentas, copias automáticas y su verificación.
1. Tres cuentas por aplicación
Una aplicación no necesita ser dueña de su base de datos para funcionar. Separa:
| Cuenta | Para qué | Permisos |
|---|---|---|
Propietaria (crm_owner) | Despliegues que cambian tablas | DDL sobre su base de datos; no se usa en el día a día |
Aplicación (crm_app) | La aplicación en marcha | Leer y escribir datos, sin crear ni borrar tablas |
Copias (backup) | El trabajo de copia | Solo lectura, solo desde el propio servidor |
En MySQL / MariaDB:
sudo mysqlCREATE USER 'crm_app'@'10.20.30.%' IDENTIFIED BY '...';GRANT SELECT, INSERT, UPDATE, DELETE ON crm.* TO 'crm_app'@'10.20.30.%';CREATE USER 'backup'@'localhost' IDENTIFIED BY '...';GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES, PROCESS, RELOAD ON *.* TO 'backup'@'localhost';SHOW GRANTS FOR 'crm_app'@'10.20.30.%';+-------------------------------------------------------------------------------+| Grants for crm_app@10.20.30.% |+-------------------------------------------------------------------------------+| GRANT USAGE ON *.* TO `crm_app`@`10.20.30.%` || GRANT SELECT, INSERT, UPDATE, DELETE ON `crm`.* TO `crm_app`@`10.20.30.%` |+-------------------------------------------------------------------------------+- El host forma parte del usuario:
crm_appsolo entra desde la red de los servidores de aplicación, ybackupsolo desdelocalhost. PROCESSevita el aviso de tablespaces de mysqldump en MySQL 8;RELOADlo pide mysqldump con--single-transactiondesde MySQL 8.0.32 cuando la base de datos usa GTID.- Nada de
GRANT ALLni de%como host para la aplicación.
En PostgreSQL, permisos a un rol de grupo y el usuario de la aplicación dentro de él:
sudo -u postgres psql -d erpCREATE ROLE erp_rw NOLOGIN;GRANT CONNECT ON DATABASE erp TO erp_rw;GRANT USAGE ON SCHEMA erp TO erp_rw;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA erp TO erp_rw;GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA erp TO erp_rw;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO erp_rw;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT USAGE, SELECT ON SEQUENCES TO erp_rw;CREATE ROLE erp_app LOGIN PASSWORD '...' IN ROLE erp_rw;CREATE ROLE backup LOGIN PASSWORD '...';GRANT pg_read_all_data TO backup;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner: las tablas que cree el propietario en el próximo despliegue llegan ya con permisos para la aplicación. Sin esto, la tabla nueva da permission denied el día del despliegue.pg_read_all_data(PostgreSQL 14 y posteriores) da lectura sobre todas las tablas y esquemas: justo lo que necesitapg_dump.- En
pg_hba.conf, cada cuenta desde donde corresponde:hostssl erp erp_app 10.20.30.0/24 scram-sha-256yhost all backup 127.0.0.1/32 scram-sha-256. Recarga conSELECT pg_reload_conf();.
2. Las contraseñas de la copia, en ficheros 0600
El trabajo de copia corre con una cuenta del sistema propia, dbbackup, y lee las contraseñas de ficheros en su carpeta personal. Nunca en el script ni en la línea de comandos (se verían en ps).
sudo useradd --system --create-home --home-dir /var/lib/dbbackup --shell /usr/sbin/nologin dbbackupsudo -u dbbackup install -m 600 /dev/null /var/lib/dbbackup/.my.cnfsudo -u dbbackup install -m 600 /dev/null /var/lib/dbbackup/.pgpasssudoedit -u dbbackup /var/lib/dbbackup/.my.cnf /var/lib/dbbackup/.pgpasssudo ls -l /var/lib/dbbackup/.my.cnf /var/lib/dbbackup/.pgpass-rw------- 1 dbbackup dbbackup 42 nov 3 10:12 /var/lib/dbbackup/.my.cnf-rw------- 1 dbbackup dbbackup 38 nov 3 10:12 /var/lib/dbbackup/.pgpass# .my.cnf[client]user=backuppassword=...# .pgpass (host:puerto:base de datos:usuario:contraseña)localhost:5432:*:backup:...- Los dos ficheros se buscan en el HOME del usuario que ejecuta el comando. Si el trabajo corre con otra cuenta o sin HOME, no los encuentra.
.pgpasscon permisos para el grupo o para otros se ignora: libpq avisa con has group or world access y sigue sin contraseña.- MySQL ignora un
.my.cnfque cualquiera pueda escribir. En MySQL (no en MariaDB) existe ademásmysql_config_editor, que guarda las credenciales ofuscadas en~/.mylogin.cnf.
3. El script y el temporizador
#!/usr/bin/env bashset -euo pipefailumask 077DEST=/backup/dbSTAMP=$(date +%F_%H%M) # MariaDB / MySQL: CRMmysqldump --single-transaction --routines --triggers --events crm | gzip > "$DEST/crm-$STAMP.sql.gz.part"zcat "$DEST/crm-$STAMP.sql.gz.part" | tail -n 1 | grep -q "Dump completed"mv "$DEST/crm-$STAMP.sql.gz.part" "$DEST/crm-$STAMP.sql.gz" # PostgreSQL: ERPpg_dump -h localhost -U backup -Fc -f "$DEST/erp-$STAMP.dump.part" erppg_restore -l "$DEST/erp-$STAMP.dump.part" > /dev/nullmv "$DEST/erp-$STAMP.dump.part" "$DEST/erp-$STAMP.dump" # Retención local: 14 díasfind "$DEST" -type f \( -name '*.sql.gz' -o -name '*.dump' \) -mtime +14 -deleteset -euo pipefail: simysqldumpfalla dentro de la tubería, el script falla. Sinpipefail, el resultado sería el degzip, que siempre termina bien aunque reciba cero bytes..partymv: solo tiene nombre definitivo una copia que ha terminado y se ha comprobado. Un.parten la carpeta es una copia rota.- Las comprobaciones: mysqldump termina el fichero con
-- Dump completed on ...;pg_restore -llee el índice del fichero-Fcy falla si está truncado. --single-transactionda una copia coherente sin bloquear las tablas InnoDB. Las tablas MyISAM no entran en esa garantía.- La base de datos va sin
--databases: así el fichero no llevaUSE crmy se puede restaurar con otro nombre (paso 4).
Un servicio y un temporizador de systemd:
[Unit]Description=Copia lógica de CRM y ERPOnFailure=notify-email@%n.service [Service]Type=oneshotUser=dbbackupExecStart=/usr/local/sbin/db-backup.sh # --- db-backup.timer ---[Unit]Description=Copia lógica diaria [Timer]OnCalendar=*-*-* 02:30:00Persistent=true [Install]WantedBy=timers.targetsudo systemctl daemon-reload && sudo systemctl enable --now db-backup.timersudo systemctl start db-backup.service && systemctl status db-backup.service --no-pager | head -3○ db-backup.service - Copia lógica de CRM y ERP Loaded: loaded (/etc/systemd/system/db-backup.service; static) Active: inactive (dead) since Tue 2026-11-03 10:31:52 CET; 4s agols -lh /backup/db | tail -2-rw------- 1 dbbackup dbbackup 412M nov 3 10:31 crm-2026-11-03_1030.sql.gz-rw------- 1 dbbackup dbbackup 1,9G nov 3 10:31 erp-2026-11-03_1030.dumpsystemctl list-timers db-backup.timer- Con
User=dbbackup, systemd pone el HOME de esa cuenta, y con él se encuentran.my.cnfy.pgpass. OnFailure=lanza otra unidad cuando la copia falla (aquí una plantillanotify-email@que envía un correo; vale cualquier aviso que use el equipo). Una copia que falla en silencio es el ticket del paso 6.Persistent=truelanza la copia al arrancar si el servidor estaba apagado a las 2:30.
4. La restauración, probada en otra base de datos
Una vez al mes, en srv-db-test, con la copia de anoche y el cronómetro en marcha:
sudo -u postgres createdb erp_restoretime sudo -u postgres pg_restore -d erp_restore --no-owner --no-privileges -j 4 /restore/erp-2026-11-03_0230.dumpreal 6m41ssudo -u postgres psql -d erp_restore -tAc "SELECT count(*), max(fecha) FROM erp.facturas;"918244|2026-11-02 23:58:11sudo mysql -e "CREATE DATABASE crm_restore"time (zcat /restore/crm-2026-11-03_0230.sql.gz | sudo mysql crm_restore)real 3m12ssudo mysql -N -e "SELECT COUNT(*), MAX(creado) FROM crm_restore.clientes;"41877 2026-11-02 21:14:03- Se restaura en una base de datos con otro nombre (
erp_restore), nunca encima de la original. Con--no-ownery--no-privilegeslos objetos quedan a nombre de quien restaura, sin necesitar que existan los roles de producción;-j 4restaura en paralelo. - Compara el recuento y la última fecha con producción a la hora de la copia: si la última factura es de hace una semana, la copia es vieja aunque restaure bien.
- Apunta el tiempo (6 minutos y 41 segundos): es tu RTO real para esa base de datos, el que se compara con lo que el negocio espera.
- Borra
erp_restoreycrm_restoreal terminar y registra la prueba: fecha, fichero, tiempo, recuentos y quién la hizo.
5. El retraso de la réplica, en un vistazo
Si hay una réplica (para lectura o para conmutar en caso de fallo), su retraso es lo primero que se mira antes de hacer copias desde ella o de conmutar.
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Replica_IO_Running|Replica_SQL_Running:|Seconds_Behind_Source|Last_SQL_Error:" Replica_IO_Running: Yes Replica_SQL_Running: Yes Seconds_Behind_Source: 0 Last_SQL_Error:En MariaDB los campos siguen llamándose Slave_IO_Running, Slave_SQL_Running y Seconds_Behind_Master (también con SHOW REPLICA STATUS\G, que existe desde la 10.5; antes, SHOW SLAVE STATUS\G), así que ajusta el grep. Los dos hilos tienen que estar en Yes; uno en No con Last_SQL_Error relleno es una réplica parada, aunque el retraso diga NULL y no un número alto.
sudo -u postgres psql -c "SELECT application_name, client_addr, state, replay_lag FROM pg_stat_replication;" application_name | client_addr | state | replay_lag------------------+--------------+-----------+----------------- srv-db-02b | 10.20.40.12 | streaming | 00:00:00.004112# en la réplica:sudo -u postgres psql -tAc "SELECT now() - pg_last_xact_replay_timestamp();"00:00:01.83Una fila vacía en pg_stat_replication es que la réplica no está conectada. En la réplica, el segundo valor crece aunque todo vaya bien si el primario no tiene escrituras: compáralo con la actividad real antes de alarmarte.
6. El ticket: "la copia de la base de datos lleva una semana dejando ficheros de 0 bytes"
Martes, 9:30. Al preparar la restauración mensual, el fichero de anoche pesa 0 bytes. Y el de la noche anterior. El servidor srv-db-03 aún usa una línea de cron antigua, no el script del paso 3:
30 2 * * * dbbackup pg_dump -h localhost -U backup -Fc erp > /backup/db/erp-$(date +\%F).dump 2>/dev/nullAsí se explica el tamaño: la redirección > la hace el shell y crea el fichero antes de que pg_dump arranque. Si pg_dump falla, queda un fichero vacío, y 2>/dev/null tira el mensaje de error. (El \% es obligatorio: en un crontab, % sin escapar es un salto de línea.)
- Desde cuándo:
ls -l --time-style=long-iso /backup/db | tailmuestra 0 bytes desde el 27 de octubre. ¿Qué cambió ese día? El registro de cambios dice que se aplicó un rol de Ansible que reescribió las carpetas personales de las cuentas de servicio. - Ejecutar lo mismo, como el mismo usuario y con el error a la vista:
sudo -u dbbackup -H pg_dump -h localhost -U backup -Fc erp -f /tmp/prueba.dumpWARNING: password file "/var/lib/dbbackup/.pgpass" has group or world access; permissions should be u=rw (0600) or lesspg_dump: error: connection to server at "localhost" (127.0.0.1), port 5432 failed: fe_sendauth: no password suppliedsudo ls -l /var/lib/dbbackup/.pgpass-rw-r--r-- 1 dbbackup dbbackup 38 oct 27 11:02 /var/lib/dbbackup/.pgpass- La causa: el rol de Ansible dejó
.pgpassen0644. libpq lo ignora por seguridad,pg_dumpse queda sin contraseña y, sin terminal, no puede pedirla. Solución:sudo chmod 600 /var/lib/dbbackup/.pgpassy corregir el rol de Ansible para que usemode: "0600". - Descarta lo demás antes de cerrar, porque los mismos 0 bytes salen de otras causas:
- Disco lleno:
df -h /backupydf -i /backup. Con el destino lleno, el fichero se crea y no se escribe. - Permisos del destino:
sudo -u dbbackup touch /backup/db/prueba. Si falla, el cron ni siquiera crearía el fichero. - En MySQL, el equivalente es
mysqldump: Got error: 1045: Access denied for user 'backup'@'localhost' (using password: NO): no ha leído.my.cnf(otro HOME u otro usuario), o lo ha descartado con World-writable config file ... is ignored.
- Copia ya, sin esperar a esta noche: el comando del punto 2 con el fichero en
/backup/db/, comprobado conpg_restore -l, y una restauración rápida ensrv-db-testcomo en el paso 4. - Que no vuelva a pasar en silencio: sustituir la línea de cron por el script y el temporizador del paso 3 (falla de verdad, deja
.party avisa conOnFailure) y añadir a la monitorización una comprobación del tamaño y la fecha del último fichero de/backup/db.
En el ticket: desde qué fecha no hay copia válida (una semana sin copia lógica del ERP: avisa a su responsable), la causa, el cambio, la copia nueva con su prueba de restauración y la mejora para detectarlo.
Siguientes pasos
- La guía de PostgreSQL, MySQL y SQL Server cubre la consulta que bloquea, el espacio y el crecimiento.
- La de Veeam explica cómo el sistema de copias recoge
/backupy lo guarda fuera del servidor. - Con bases de datos grandes o un RPO de minutos, la copia lógica se queda corta: copias físicas con archivado de WAL (pgBackRest) o binlogs (MariaDB Backup, Percona XtraBackup).
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
What you'll get
The internal applications' databases (the CRM on MariaDB, the ERP on PostgreSQL) with separate accounts and least privilege, a nightly logical backup that fails visibly when something goes wrong, a tested restore into another database with its time recorded, replication lag in view, and the routine for the ticket nobody wants: the backup has been producing 0-byte files for a week.
Before you start
- MySQL 8.0 or 8.4, or MariaDB 10.11 or 11.4, and PostgreSQL 15 or later, on Linux, with
sudoon the server. - A backup target that is not the data disk (
/backup, mounted from another array or a NAS) and that the backup system (Veeam, tape) then picks up. - A test server (
srv-db-test) with the same engine versions to restore to. - The PostgreSQL, MySQL and SQL Server guide covers basic queries, disk space and the slow query; this one is about accounts, automated backups and verifying them.
1. Three accounts per application
An application does not need to own its database to work. Split them:
| Account | What for | Rights |
|---|---|---|
Owner (crm_owner) | Deployments that change tables | DDL on its database; not used day to day |
Application (crm_app) | The running application | Read and write data, no creating or dropping tables |
Backup (backup) | The backup job | Read-only, only from the server itself |
On MySQL / MariaDB:
sudo mysqlCREATE USER 'crm_app'@'10.20.30.%' IDENTIFIED BY '...';GRANT SELECT, INSERT, UPDATE, DELETE ON crm.* TO 'crm_app'@'10.20.30.%';CREATE USER 'backup'@'localhost' IDENTIFIED BY '...';GRANT SELECT, SHOW VIEW, TRIGGER, EVENT, LOCK TABLES, PROCESS, RELOAD ON *.* TO 'backup'@'localhost';SHOW GRANTS FOR 'crm_app'@'10.20.30.%';+-------------------------------------------------------------------------------+| Grants for crm_app@10.20.30.% |+-------------------------------------------------------------------------------+| GRANT USAGE ON *.* TO `crm_app`@`10.20.30.%` || GRANT SELECT, INSERT, UPDATE, DELETE ON `crm`.* TO `crm_app`@`10.20.30.%` |+-------------------------------------------------------------------------------+- The host is part of the user:
crm_apponly gets in from the application servers' network, andbackuponly fromlocalhost. PROCESSavoids mysqldump's tablespaces warning on MySQL 8;RELOADis requested by mysqldump with--single-transactionsince MySQL 8.0.32 when the database uses GTIDs.- No
GRANT ALLand no%as the host for the application.
On PostgreSQL, rights go to a group role and the application user sits inside it:
sudo -u postgres psql -d erpCREATE ROLE erp_rw NOLOGIN;GRANT CONNECT ON DATABASE erp TO erp_rw;GRANT USAGE ON SCHEMA erp TO erp_rw;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA erp TO erp_rw;GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA erp TO erp_rw;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO erp_rw;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner IN SCHEMA erp GRANT USAGE, SELECT ON SEQUENCES TO erp_rw;CREATE ROLE erp_app LOGIN PASSWORD '...' IN ROLE erp_rw;CREATE ROLE backup LOGIN PASSWORD '...';GRANT pg_read_all_data TO backup;ALTER DEFAULT PRIVILEGES FOR ROLE erp_owner: tables the owner creates in the next deployment arrive already granted to the application. Without it, the new table gives permission denied on deployment day.pg_read_all_data(PostgreSQL 14 and later) grants read access on every table and schema: exactly whatpg_dumpneeds.- In
pg_hba.conf, each account from where it belongs:hostssl erp erp_app 10.20.30.0/24 scram-sha-256andhost all backup 127.0.0.1/32 scram-sha-256. Reload withSELECT pg_reload_conf();.
2. The backup passwords, in 0600 files
The backup job runs under its own system account, dbbackup, and reads the passwords from files in its home folder. Never in the script or on the command line (they would show up in ps).
sudo useradd --system --create-home --home-dir /var/lib/dbbackup --shell /usr/sbin/nologin dbbackupsudo -u dbbackup install -m 600 /dev/null /var/lib/dbbackup/.my.cnfsudo -u dbbackup install -m 600 /dev/null /var/lib/dbbackup/.pgpasssudoedit -u dbbackup /var/lib/dbbackup/.my.cnf /var/lib/dbbackup/.pgpasssudo ls -l /var/lib/dbbackup/.my.cnf /var/lib/dbbackup/.pgpass-rw------- 1 dbbackup dbbackup 42 Nov 3 10:12 /var/lib/dbbackup/.my.cnf-rw------- 1 dbbackup dbbackup 38 Nov 3 10:12 /var/lib/dbbackup/.pgpass# .my.cnf[client]user=backuppassword=...# .pgpass (host:port:database:user:password)localhost:5432:*:backup:...- Both files are looked up in the HOME of the user running the command. If the job runs under another account or with no HOME, it does not find them.
- A
.pgpasswith group or world permissions is ignored: libpq warns has group or world access and carries on without a password. - MySQL ignores a
.my.cnfthat anyone can write to. MySQL (not MariaDB) also hasmysql_config_editor, which stores obfuscated credentials in~/.mylogin.cnf.
3. The script and the timer
#!/usr/bin/env bashset -euo pipefailumask 077DEST=/backup/dbSTAMP=$(date +%F_%H%M) # MariaDB / MySQL: CRMmysqldump --single-transaction --routines --triggers --events crm | gzip > "$DEST/crm-$STAMP.sql.gz.part"zcat "$DEST/crm-$STAMP.sql.gz.part" | tail -n 1 | grep -q "Dump completed"mv "$DEST/crm-$STAMP.sql.gz.part" "$DEST/crm-$STAMP.sql.gz" # PostgreSQL: ERPpg_dump -h localhost -U backup -Fc -f "$DEST/erp-$STAMP.dump.part" erppg_restore -l "$DEST/erp-$STAMP.dump.part" > /dev/nullmv "$DEST/erp-$STAMP.dump.part" "$DEST/erp-$STAMP.dump" # Local retention: 14 daysfind "$DEST" -type f \( -name '*.sql.gz' -o -name '*.dump' \) -mtime +14 -deleteset -euo pipefail: ifmysqldumpfails inside the pipe, the script fails. Withoutpipefail, the result would begzip's, which always succeeds even when it receives zero bytes..partandmv: only a backup that has finished and been checked gets its final name. A.partleft in the folder is a broken backup.- The checks: mysqldump ends the file with
-- Dump completed on ...;pg_restore -lreads the table of contents of the-Fcfile and fails if it is truncated. --single-transactiongives a consistent copy without locking InnoDB tables. MyISAM tables are outside that guarantee.- The database is dumped without
--databases: that way the file carries noUSE crmand can be restored under another name (step 4).
A systemd service and timer:
[Unit]Description=Logical backup of CRM and ERPOnFailure=notify-email@%n.service [Service]Type=oneshotUser=dbbackupExecStart=/usr/local/sbin/db-backup.sh # --- db-backup.timer ---[Unit]Description=Daily logical backup [Timer]OnCalendar=*-*-* 02:30:00Persistent=true [Install]WantedBy=timers.targetsudo systemctl daemon-reload && sudo systemctl enable --now db-backup.timersudo systemctl start db-backup.service && systemctl status db-backup.service --no-pager | head -3○ db-backup.service - Logical backup of CRM and ERP Loaded: loaded (/etc/systemd/system/db-backup.service; static) Active: inactive (dead) since Tue 2026-11-03 10:31:52 CET; 4s agols -lh /backup/db | tail -2-rw------- 1 dbbackup dbbackup 412M Nov 3 10:31 crm-2026-11-03_1030.sql.gz-rw------- 1 dbbackup dbbackup 1.9G Nov 3 10:31 erp-2026-11-03_1030.dumpsystemctl list-timers db-backup.timer- With
User=dbbackup, systemd sets that account's HOME, and with it.my.cnfand.pgpassare found. OnFailure=starts another unit when the backup fails (here anotify-email@template that sends an email; any alert the team uses will do). A backup that fails silently is the step 6 ticket.Persistent=trueruns the backup at boot if the server was off at 2:30.
4. The restore, tested into another database
Once a month, on srv-db-test, with last night's backup and a stopwatch running:
sudo -u postgres createdb erp_restoretime sudo -u postgres pg_restore -d erp_restore --no-owner --no-privileges -j 4 /restore/erp-2026-11-03_0230.dumpreal 6m41ssudo -u postgres psql -d erp_restore -tAc "SELECT count(*), max(fecha) FROM erp.facturas;"918244|2026-11-02 23:58:11sudo mysql -e "CREATE DATABASE crm_restore"time (zcat /restore/crm-2026-11-03_0230.sql.gz | sudo mysql crm_restore)real 3m12ssudo mysql -N -e "SELECT COUNT(*), MAX(creado) FROM crm_restore.clientes;"41877 2026-11-02 21:14:03- Restore into a database with another name (
erp_restore), never over the original. With--no-ownerand--no-privilegesthe objects belong to whoever restores them, without needing the production roles to exist;-j 4restores in parallel. - Compare the count and the latest date with production at backup time: if the last invoice is a week old, the backup is stale even though it restores fine.
- Record the time (6 minutes 41 seconds): it is your real RTO for that database, the one you compare with what the business expects.
- Drop
erp_restoreandcrm_restorewhen done and log the test: date, file, time, counts and who did it.
5. Replication lag at a glance
If there is a replica (for reads or for failover), its lag is the first thing to check before taking backups from it or failing over.
sudo mysql -e "SHOW REPLICA STATUS\G" | grep -E "Replica_IO_Running|Replica_SQL_Running:|Seconds_Behind_Source|Last_SQL_Error:" Replica_IO_Running: Yes Replica_SQL_Running: Yes Seconds_Behind_Source: 0 Last_SQL_Error:On MariaDB the fields are still called Slave_IO_Running, Slave_SQL_Running and Seconds_Behind_Master (also with SHOW REPLICA STATUS\G, available since 10.5; before that, SHOW SLAVE STATUS\G), so adjust the grep. Both threads must be Yes; one on No with Last_SQL_Error filled in is a stopped replica, even though the lag shows NULL rather than a high number.
sudo -u postgres psql -c "SELECT application_name, client_addr, state, replay_lag FROM pg_stat_replication;" application_name | client_addr | state | replay_lag------------------+--------------+-----------+----------------- srv-db-02b | 10.20.40.12 | streaming | 00:00:00.004112# on the replica:sudo -u postgres psql -tAc "SELECT now() - pg_last_xact_replay_timestamp();"00:00:01.83An empty result from pg_stat_replication means the replica is not connected. On the replica, the second value grows even when everything is fine if the primary has no writes: compare it with real activity before raising the alarm.
6. The ticket: "the database backup has been producing 0-byte files for a week"
Tuesday, 9:30. While preparing the monthly restore, last night's file is 0 bytes. So is the one from the night before. Server srv-db-03 still uses an old cron line, not the step 3 script:
30 2 * * * dbbackup pg_dump -h localhost -U backup -Fc erp > /backup/db/erp-$(date +\%F).dump 2>/dev/nullThat explains the size: the > redirection is done by the shell, which creates the file before pg_dump starts. If pg_dump fails, an empty file is left behind, and 2>/dev/null throws the error message away. (The \% is required: in a crontab, an unescaped % is a newline.)
- Since when:
ls -l --time-style=long-iso /backup/db | tailshows 0 bytes since 27 October. What changed that day? The change log says an Ansible role was applied that rewrote the service accounts' home folders. - Run the same thing, as the same user, with the error in view:
sudo -u dbbackup -H pg_dump -h localhost -U backup -Fc erp -f /tmp/test.dumpWARNING: password file "/var/lib/dbbackup/.pgpass" has group or world access; permissions should be u=rw (0600) or lesspg_dump: error: connection to server at "localhost" (127.0.0.1), port 5432 failed: fe_sendauth: no password suppliedsudo ls -l /var/lib/dbbackup/.pgpass-rw-r--r-- 1 dbbackup dbbackup 38 Oct 27 11:02 /var/lib/dbbackup/.pgpass- The cause: the Ansible role left
.pgpassat0644. libpq ignores it for safety,pg_dumphas no password and, with no terminal, cannot ask for one. Fix:sudo chmod 600 /var/lib/dbbackup/.pgpassand correct the Ansible role to usemode: "0600". - Rule out the rest before closing, because the same 0 bytes come from other causes:
- Disk full:
df -h /backupanddf -i /backup. With the target full, the file is created and never written. - Target permissions:
sudo -u dbbackup touch /backup/db/test. If it fails, cron would not even create the file. - On MySQL, the equivalent is
mysqldump: Got error: 1045: Access denied for user 'backup'@'localhost' (using password: NO): it has not read.my.cnf(another HOME or another user), or it discarded it with World-writable config file ... is ignored.
- Back up now, without waiting for tonight: the command from point 2 writing into
/backup/db/, checked withpg_restore -l, and a quick restore onsrv-db-testas in step 4. - Make sure it never fails silently again: replace the cron line with the step 3 script and timer (it fails for real, leaves a
.partand alerts throughOnFailure) and add a monitoring check on the size and date of the latest file in/backup/db.
In the ticket: since when there has been no valid backup (a week without a logical backup of the ERP: tell its owner), the cause, the change, the new backup with its restore test, and the improvement to detect it.
Next steps
- The PostgreSQL, MySQL and SQL Server guide covers the blocking query, disk space and growth.
- The Veeam guide explains how the backup system picks up
/backupand keeps it off the server. - With large databases or an RPO of minutes, logical backups fall short: physical backups with WAL archiving (pgBackRest) or binlogs (MariaDB Backup, Percona XtraBackup).
Is something missing, or different in your environment? Get in touch and I'll fix it.
Tech Career Lab · newsletter
¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →