My n8n Backups Had Been Empty for Months — Here’s Why
My backup job was running every day and creating files exactly as expected. There was just one problem: most of those database backups were completely empty.
Self-hosting · Backups · n8n
The backups looked completely normal
For a long time, I thought my n8n backups were working.
There was a cron job. It ran every night at 03:00. New .sql files appeared in the backup directory with fresh timestamps. At a glance, everything looked exactly as I expected.
Then, on May 22, I actually looked at the files. Most of them were 0 bytes. Not just one failed backup, but there were empty SQL files spread across March, April and May. The automation had been creating convincing-looking filenames for months without actually giving me a usable database backup. That was a pretty uncomfortable discovery, especially because n8n had already become an important part of my homelab.
What the old backup was doing
The old cron job ran this script every night:
0 3 * * * /home/homelab/backups/n8n/backup_n8n.shThe important part of the script looked roughly like this:
sudo docker exec -t n8n-postgres-1 \
pg_dump -U n8n -d n8n > "$FILENAME"The intention was completely reasonable: run pg_dump inside the PostgreSQL container and redirect the resulting SQL into a file.
The problem was that the backup command itself was failing while the shell had already created the destination file.
So from the outside I got this:
n8n_2026-05-18_03-00-01.sql 0 B
n8n_2026-05-19_03-00-01.sql 0 B
n8n_2026-05-20_03-00-01.sql 0 B
n8n_2026-05-21_03-00-01.sql 0 BA timestamped file existed, but there was no backup inside it.
I could not prove afterwards which individual part of the old command had caused every failed run. There were several problems with the design: it used sudo from a non-interactive cron environment, requested a pseudo-terminal with docker exec -t, and had no useful logging that would have made the failures obvious at the time.
What mattered was that the backup process clearly could not be trusted.
Rebuilding the backup
Instead of trying to keep the old script alive, I rebuilt the backup around the actual Docker Compose stack. The PostgreSQL dump became:
sudo docker compose exec -T postgres \
pg_dump -U n8n -d n8n > "$BACKUP_DIR/n8n-db-$BACKUP_DATE.sql"The small change from -t to -T is important here.
-t asks Docker for a pseudo-terminal. -T explicitly disables that behaviour, which is what I want for a non-interactive script running from cron. The first successful dump was around 388–389 MB instead of 0 bytes. That was a fairly convincing difference.
I also moved the backups into the central application-backup structure on my NAS:
/mnt/nas/backups/apps/n8n/Each run now gets its own timestamped directory containing the PostgreSQL dump, the Docker Compose configuration and the relevant n8n appdata. 02_HOMELAB_SERVER_APPS_BACKUPS_…
Then I accidentally found 13 GB of n8n data
While building the new backup, I initially archived the entire n8n appdata directory as well.
The resulting archive was around 2.5 GB, which seemed ridiculous for what I expected to be a relatively small amount of configuration. So I looked deeper. The complete n8n appdata directory had grown to roughly 13 GB, almost entirely because of binaryData.
One workflow had accumulated around 180 binary files, many of them roughly 200 MB each, from previous executions.
That data can be useful if I ever want to reconstruct old executions including their binary payloads, but it did not make much sense to copy all of it into every daily backup.
So the daily backup now excludes it:
sudo tar \
--exclude='n8n/binaryData' \
-czf "$BACKUP_DIR/n8n-appdata-no-binary-$BACKUP_DATE.tar.gz" \
-C /srv/n8n n8nThat brought the appdata portion of the backup down to roughly 1.2 MB, while the important PostgreSQL dump remained around 389 MB. The daily backup therefore became:
docker-compose.yml
n8n-db-<timestamp>.sql
n8n-appdata-no-binary-<timestamp>.tar.gzMuch cleaner.
Making it automatic again
Once the new script had been tested manually, I replaced the old cron job with:
0 3 * * * /home/homelab/scripts/backup-n8n.sh \
>> /home/homelab/scripts/backup-n8n.log 2>&1That last part matters almost as much as the backup itself. The job now has a log. If something fails, I no longer have to infer that from a suspiciously small file months later.
The next day's automatic backup produced another real PostgreSQL dump of roughly 390 MB, which confirmed that the new method also worked outside my manual test.
The bigger lesson
The most important thing I learned from this was that:
a scheduled backup is not the same thing as a working backup.
My cron job was running. The directory was filling up. The filenames had correct dates. And the backups were still useless. Since then, backup monitoring has become a much bigger part of my homelab.
I later gave n8n read-only access to the application-backup directories and built monitoring around application health and backup freshness. The idea is simple: healthy systems stay quiet, but stale or missing backups should become visible instead of silently accumulating for months.
That philosophy has since evolved even further. I now have a dedicated Backup & Recovery Engineer in my Hermes Operations setup. It can inspect bounded backup-health and restore-readiness evidence from Home Assistant, but it deliberately cannot delete backups, initiate restores or automatically “fix” anything.
The distinction I now try to make everywhere is: runtime health ≠ backup health ≠ restore readiness
An application responding normally tells me that it works right now. It says almost nothing about whether I can recover it tomorrow.
And that is probably the biggest thing those 0-byte SQL files taught me.