n8n SQLITE_NOTADB and SQLITE_IOERR on Docker

Two errors, two days, two different root causes, and the same underlying mistake.

Summary: My self-hosted n8n database became unreadable twice. The first failure was SQLITE_NOTADB, caused by bind-mounting the database file instead of its directory. I fixed that, and then hit SQLITE_IOERR, caused by SQLite's write-ahead log not staying coherent over the shared filesystem between host and VM. The real fix for both is to stop keeping the database on a host bind mount.

Symptom one: file is not a database

n8n fails at startup or mid-run with:

SQLITE_NOTADB: file is not a database

This one is almost always a mounting mistake rather than genuine corruption, and it is worth checking before you reach for recovery tools.

Docker compose files frequently mount the database file directly, something shaped like ./data/database.sqlite:/home/node/.n8n/database.sqlite. That works right up until the host path does not exist at container start. Docker's behaviour then is not to fail, it is to create something at that path for you, typically an empty directory or a zero-byte file. SQLite opens it, finds no header, and reports exactly what it sees.

The message is honest. The file genuinely is not a database. It is a placeholder Docker invented.

Check first: look at the size of the file on the host. If it is zero bytes, nothing is corrupted and no data is lost from that file, because it never held any. Your real database is somewhere else, or was replaced.

Fix: mount the parent directory, not the file.

- ./data:/home/node/.n8n

A directory mount has no ambiguity about what Docker should create. This is a good rule beyond n8n: never bind-mount an individual file that a program expects to create or replace, because atomic-replace patterns break the mount and this class of surprise follows.

Symptom two: disk I/O error

I made that change, felt clever, and a day later got:

SQLITE_IOERR: disk I/O error

Different error, different cause, and the fix for the first problem did not touch it.

This one is about where the database lives rather than how it is named. On a Mac, Docker runs inside a virtual machine, and a host bind mount crosses a shared filesystem between the host and that VM. Colima uses virtiofs for this; Docker Desktop has used various mechanisms over the years with a similar shape.

SQLite in WAL mode does not just read and write a file. It memory-maps a shared-memory index, the -shm file, to coordinate readers and writers. Memory-mapped coherence across a host-to-VM filesystem boundary is a much stronger guarantee than ordinary file reads and writes, and it is not reliably provided.

The reason this bites people so hard is the timing. It works fine for months. Light traffic never stresses it. Then something does a heavy write and the coherence assumption fails all at once.

In my case the trigger was n8n's own maintenance. The database had grown to about 2.1 GB of execution history and a vacuum compacted it to roughly 173 MB. That is an enormous amount of rewriting in one operation, and it is where the mapping fell apart. The database had been fine that morning.

Fix: get the database off the bind mount and onto the VM's own disk by using a Docker named volume.

volumes:
  - n8n_home:/home/node/.n8n

You can still bind-mount read-only configuration alongside it. It is the database that must not cross the boundary.

Two landmines when you migrate

Moving to a named volume means copying an existing database into a fresh location, and there are two ways to lose everything while feeling organised.

The encryption key. n8n encrypts stored credentials with a key that lives alongside the database. When I compared the key in the old location with the one in the new environment, they were different. Copying the database without the matching key gives you a working n8n whose every stored credential is unreadable. Verify the key matches before you start, not after.

Community nodes. The set of installed community nodes in the old environment did not match the new one. A workflow referencing a node that is not installed does not degrade gracefully; it fails when it runs, which may be at three in the morning rather than during your migration. Compare the installed set explicitly.

Both of these are invisible if you only check that n8n starts and the workflow list looks right.

Back up in a way that survives WAL

If you are copying database.sqlite with cp while n8n is running, your backups may not be what you think.

In WAL mode, recent transactions live in a separate write-ahead log. Copy the main file on its own, at the wrong moment, and you get a database that appears fine and restores into something inconsistent. You will find out during an incident, which is the worst possible time to discover a backup problem.

Use SQLite's own backup command, which is designed to be safe against a live database:

sqlite3 database.sqlite ".backup '/path/to/backup.sqlite'"

Then actually restore one somewhere harmless and open it. An unverified backup is a belief, not a backup.

What I would do differently

Three things, in order of how much they would have helped.

Monitor the thing users touch. My contact form fed this n8n instance, and it was broken for about twenty-four hours before I noticed. The database error was in the logs the whole time. Nothing was watching. A check every few minutes against the real endpoint turns a day-long silent outage into a fifteen-minute one, and it is a small script.

Cap the execution history. A 2.1 GB database of execution logs is not a feature. n8n has pruning settings for exactly this. The vacuum only became dangerous because the database had been allowed to grow until compacting it was a major operation.

Move to Postgres if it matters. SQLite is genuinely fine for a single-instance n8n, and I am not going to tell you to rip it out. But if the instance is load bearing for something you promise other people, the container filesystem question stops being interesting the moment the database is a separate service.

The one-paragraph version

If you see SQLITE_NOTADB, check whether you bind-mounted the file rather than the directory, and check whether the file is zero bytes. If you see SQLITE_IOERR on a Mac or any Docker setup with a VM in the middle, your database is on a shared filesystem that cannot support what SQLite's write-ahead log expects, and moving it into a named volume is the fix. And whatever else you do, watch the endpoint your customers actually use, because both of these failed silently while everything looked fine from the outside.

Related reading

Running automation you cannot afford to have fail quietly?

Book a free 20-minute call. Bring what you've built and we'll look at what happens when it breaks, and whether you'd know.

Raleigh-Durham, NC. Veteran-owned. No pressure, no pitch.