GuíasGuides · Nivel 3Level 3

MySQL, MariaDB y PostgreSQL: usuarios con permisos mínimos, copia lógica programada, restauración probada, retraso de réplica y copias de 0 bytesMySQL, MariaDB and PostgreSQL: least-privilege users, a scheduled logical backup, a tested restore, replication lag and 0-byte backups

Una cuenta para la aplicación, otra para las copias y ninguna con más permisos de los necesarios, mysqldump y pg_dump en un script con temporizador de systemd, las contraseñas en ~/.my.cnf y .pgpass con 0600, la restauración probada en otra base de datos, el retraso de la réplica y una semana de copias vacías.One account for the application, another for backups and none with more rights than it needs, mysqldump and pg_dump in a script with a systemd timer, passwords in ~/.my.cnf and .pgpass with 0600, the tested restore into another database, replication lag, and a week of empty backups.

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 sudo en 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:

CuentaPara quéPermisos
Propietaria (crm_owner)Despliegues que cambian tablasDDL sobre su base de datos; no se usa en el día a día
Aplicación (crm_app)La aplicación en marchaLeer y escribir datos, sin crear ni borrar tablas
Copias (backup)El trabajo de copiaSolo lectura, solo desde el propio servidor

En MySQL / MariaDB:

mysql · srv-db-01
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_app solo entra desde la red de los servidores de aplicación, y backup solo desde localhost.
  • PROCESS evita el aviso de tablespaces de mysqldump en MySQL 8; RELOAD lo pide mysqldump con --single-transaction desde MySQL 8.0.32 cuando la base de datos usa GTID.
  • Nada de GRANT ALL ni 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:

psql · srv-db-02
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 necesita pg_dump.
  • En pg_hba.conf, cada cuenta desde donde corresponde: hostssl erp erp_app 10.20.30.0/24 scram-sha-256 y host all backup 127.0.0.1/32 scram-sha-256. Recarga con SELECT 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).

bash · srv-db-01 y srv-db-02
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
~dbbackup/.my.cnf y ~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.
  • .pgpass con 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.cnf que cualquiera pueda escribir. En MySQL (no en MariaDB) existe además mysql_config_editor, que guarda las credenciales ofuscadas en ~/.mylogin.cnf.

3. El script y el temporizador

/usr/local/sbin/db-backup.sh
#!/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 -delete
  • set -euo pipefail: si mysqldump falla dentro de la tubería, el script falla. Sin pipefail, el resultado sería el de gzip, que siempre termina bien aunque reciba cero bytes.
  • .part y mv: solo tiene nombre definitivo una copia que ha terminado y se ha comprobado. Un .part en la carpeta es una copia rota.
  • Las comprobaciones: mysqldump termina el fichero con -- Dump completed on ...; pg_restore -l lee el índice del fichero -Fc y falla si está truncado.
  • --single-transaction da 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 lleva USE crm y se puede restaurar con otro nombre (paso 4).

Un servicio y un temporizador de systemd:

/etc/systemd/system/db-backup.service y db-backup.timer
[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.target
bash · srv-db-01
sudo 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.cnf y .pgpass.
  • OnFailure= lanza otra unidad cuando la copia falla (aquí una plantilla notify-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=true lanza 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:

bash · srv-db-test
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-owner y --no-privileges los objetos quedan a nombre de quien restaura, sin necesitar que existan los roles de producción; -j 4 restaura 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_restore y crm_restore al 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.

mysql · réplica srv-db-01b
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.

psql · srv-db-02 (primario) y srv-db-02b (réplica)
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.83

Una 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:

/etc/cron.d/backup-erp
30 2 * * * dbbackup pg_dump -h localhost -U backup -Fc erp > /backup/db/erp-$(date +\%F).dump 2>/dev/null

Así 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.)

  1. Desde cuándo: ls -l --time-style=long-iso /backup/db | tail muestra 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.
  2. Ejecutar lo mismo, como el mismo usuario y con el error a la vista:
bash · srv-db-03
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
  1. La causa: el rol de Ansible dejó .pgpass en 0644. libpq lo ignora por seguridad, pg_dump se queda sin contraseña y, sin terminal, no puede pedirla. Solución: sudo chmod 600 /var/lib/dbbackup/.pgpass y corregir el rol de Ansible para que use mode: "0600".
  2. Descarta lo demás antes de cerrar, porque los mismos 0 bytes salen de otras causas:
  • Disco lleno: df -h /backup y df -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.
  1. Copia ya, sin esperar a esta noche: el comando del punto 2 con el fichero en /backup/db/, comprobado con pg_restore -l, y una restauración rápida en srv-db-test como en el paso 4.
  2. 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 .part y avisa con OnFailure) 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 /backup y 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 sudo on 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:

AccountWhat forRights
Owner (crm_owner)Deployments that change tablesDDL on its database; not used day to day
Application (crm_app)The running applicationRead and write data, no creating or dropping tables
Backup (backup)The backup jobRead-only, only from the server itself

On MySQL / MariaDB:

mysql · srv-db-01
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_app only gets in from the application servers' network, and backup only from localhost.
  • PROCESS avoids mysqldump's tablespaces warning on MySQL 8; RELOAD is requested by mysqldump with --single-transaction since MySQL 8.0.32 when the database uses GTIDs.
  • No GRANT ALL and no % as the host for the application.

On PostgreSQL, rights go to a group role and the application user sits inside it:

psql · srv-db-02
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 what pg_dump needs.
  • In pg_hba.conf, each account from where it belongs: hostssl erp erp_app 10.20.30.0/24 scram-sha-256 and host all backup 127.0.0.1/32 scram-sha-256. Reload with SELECT 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).

bash · srv-db-01 and srv-db-02
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
~dbbackup/.my.cnf and ~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 .pgpass with group or world permissions is ignored: libpq warns has group or world access and carries on without a password.
  • MySQL ignores a .my.cnf that anyone can write to. MySQL (not MariaDB) also has mysql_config_editor, which stores obfuscated credentials in ~/.mylogin.cnf.

3. The script and the timer

/usr/local/sbin/db-backup.sh
#!/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 -delete
  • set -euo pipefail: if mysqldump fails inside the pipe, the script fails. Without pipefail, the result would be gzip's, which always succeeds even when it receives zero bytes.
  • .part and mv: only a backup that has finished and been checked gets its final name. A .part left in the folder is a broken backup.
  • The checks: mysqldump ends the file with -- Dump completed on ...; pg_restore -l reads the table of contents of the -Fc file and fails if it is truncated.
  • --single-transaction gives a consistent copy without locking InnoDB tables. MyISAM tables are outside that guarantee.
  • The database is dumped without --databases: that way the file carries no USE crm and can be restored under another name (step 4).

A systemd service and timer:

/etc/systemd/system/db-backup.service and db-backup.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.target
bash · srv-db-01
sudo 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.cnf and .pgpass are found.
  • OnFailure= starts another unit when the backup fails (here a notify-email@ template that sends an email; any alert the team uses will do). A backup that fails silently is the step 6 ticket.
  • Persistent=true runs 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:

bash · srv-db-test
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-owner and --no-privileges the objects belong to whoever restores them, without needing the production roles to exist; -j 4 restores 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_restore and crm_restore when 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.

mysql · replica srv-db-01b
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.

psql · srv-db-02 (primary) and srv-db-02b (replica)
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.83

An 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:

/etc/cron.d/backup-erp
30 2 * * * dbbackup pg_dump -h localhost -U backup -Fc erp > /backup/db/erp-$(date +\%F).dump 2>/dev/null

That 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.)

  1. Since when: ls -l --time-style=long-iso /backup/db | tail shows 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.
  2. Run the same thing, as the same user, with the error in view:
bash · srv-db-03
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
  1. The cause: the Ansible role left .pgpass at 0644. libpq ignores it for safety, pg_dump has no password and, with no terminal, cannot ask for one. Fix: sudo chmod 600 /var/lib/dbbackup/.pgpass and correct the Ansible role to use mode: "0600".
  2. Rule out the rest before closing, because the same 0 bytes come from other causes:
  • Disk full: df -h /backup and df -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.
  1. Back up now, without waiting for tonight: the command from point 2 writing into /backup/db/, checked with pg_restore -l, and a quick restore on srv-db-test as in step 4.
  2. Make sure it never fails silently again: replace the cron line with the step 3 script and timer (it fails for real, leaves a .part and alerts through OnFailure) 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 /backup and 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 →