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.sh

The 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 B

A 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 n8n

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

Much 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>&1

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

Previous
Previous

Why My Raspberry Pi Had 20% Packet Loss on Gigabit Ethernet

Next
Next

One Wrong VLAN Broke My Entire Home Assistant Energy Dashboard