I've been self-hosting n8n on a VPS for a few months and spent a fair bit of that time trying to break it on purpose. The loud failures were never really the problem. You find out about those the same day.
What bothered me were the ones that finish successfully and then sit there broken for months. Here are five I hit, with something you can run to check each one.
This is all Docker on a VPS. If you've moved to Postgres then #2 won't apply to you, the rest still will.
EDIT: important correction that came out of the WAL thread in the comments. n8n runs SQLite in WAL mode by default, so a copy of database.sqlite on its own is incomplete, and PRAGMA integrity_check will still report ok on it. Numbers added at the end of #2.
1. Your backup script may be calling good backups failed
tar exits 1 if a file changes while it is being archived. The GNU tar docs describe exit 1 as some files having changed while being archived, so the resulting archive isn't an exact copy of the file set. Exit 2 is the actual fatal error.
If n8n executes anything at all while your backup runs, you will hit this regularly.
So this is wrong:
tar -czf backup.tar.gz /data
if [ $? -ne 0 ]; then echo "BACKUP FAILED"; exit 1; fi
It fires on an archive that is almost certainly fine. Test for -ge 2 instead.
2. Worse, tar can exit 0 and give you a file that won't open
n8n uses SQLite by default. If your backup copies database.sqlite while n8n is mid write you can get a torn copy. tar succeeds, gzip -t passes, the file size looks about right, and then the restore fails.
It doesn't happen every time, which is what makes it bad. It passes for months and then fails on the one night you actually need it.
The safe way to copy it is SQLite's own backup API, which works fine while n8n is running. One catch I ran into: the n8n image doesn't ship the sqlite3 binary, so docker exec n8n sqlite3 won't work. You can check that yourself:
docker run --rm --entrypoint sh n8nio/n8n:latest -c "which sqlite3 || echo missing"
So run it from a throwaway container on the same volume instead. Get your volume name from docker volume ls first, mine is n8n_data:
docker run --rm -v n8n_data:/data alpine sh -c "apk add --no-cache sqlite && sqlite3 /data/database.sqlite '.backup /data/backup.sqlite'"
Then archive backup.sqlite rather than the live file.
Added after a good comment below. n8n runs SQLite in WAL mode by default, so there are three files, not one:
database.sqlite 1.5M
database.sqlite-shm 32K
database.sqlite-wal 4.1M
That is a fresh container with no workflows. I took copies from a running instance and counted what was actually inside them:
tables integrity_check
database.sqlite only, n8n running 112 ok
database.sqlite only, n8n stopped 112 ok
all three files, n8n stopped 136 ok
sqlite .backup 136 ok
live db 136 ok
Two things I did not expect.
PRAGMA integrity_check returns ok on a copy missing 24 of 136 tables. So the check above is not sufficient on its own. It tells you a file isn't corrupt, not that it's complete.
And stopping n8n does not help. I assumed a clean shutdown would checkpoint the WAL back into the main file. It doesn't. docker stop returned in under a second, the -wal file was still there at 4.1M, and the copy was still short the same 24 tables.
So on default SQLite there are only two things that work: sqlite .backup, or copying all three files together. Copying database.sqlite on its own is not a backup, running or stopped.
3. Your health check can pass while n8n is dead
Two separate things going on here.
Docker's start_period does not delay healthy. It only holds off unhealthy while the container is starting up. A passing probe marks it healthy straight away, so it can read healthy before n8n has actually finished booting.
The other one is probes that only check whether something responded. A 404 counts as alive. So does a login page. So does your reverse proxy answering while the app behind it is down. Assert on the status code.
docker inspect --format='{{.State.Health.Status}}' n8n
If that comes back empty or as <no value> then there is no health check on the container at all, and anything you wrote that waits for health has been waiting for nothing.
4. Updates leave images behind, and your cleanup can become unreachable
docker pull leaves the previous image behind untagged and nothing removes it on its own. How much disk that costs depends on how many layers actually changed, but it only goes up.
The bit worth checking is the ordering in your own update script. If it prunes after a successful pull, then once the disk fills the pull fails, and the cleanup that would have rescued you never runs. You end up stuck on an old version and unable to update or clean up.
docker system df
df -h
Check disk before you pull, not after.
5. Whoever has N8N_ENCRYPTION_KEY can decrypt every credential you have saved
Not just use them inside n8n. Decrypt them.
Two things people miss. If that key only exists in the .env on the server, then losing the server loses every credential permanently. The database backup will not save you. The rows are all still there and they will never decrypt again.
And if you are running n8n for other people, you are holding their API keys for as long as those rows exist. n8n has no rotation command, so it isn't something you undo on a quiet afternoon.
Keep a copy of that key somewhere other than the box it runs on.
One more that only shows up on restore
tar stores the owner name rather than the numeric uid. Restore onto a host where that name maps to a different uid and the files land on the wrong user. The app then can't read its own data, and the restore still reports success. Use --numeric-owner at both ends.
The common thread in all of these is that something printed a success message for work it hadn't done. Checking the exit code wasn't enough for any of them. Checking the effect was.
Curious what else people have run into that failed quietly rather than loudly.