What
The n8n instance on our production server stopped processing work. One workflow, a telemetry collector that receives a stream of concurrent webhook calls, had overloaded the database and taken the whole instance down with it.
The numbers when we looked:
- 300+ executions stuck in
runningstate - 346 executions in
newstate that never started - 161 executions failed with
LockAcquireTimeoutError - The UI was slow enough to be unusable
The error behind all of it:
LockAcquireTimeoutError: Timeout waiting for lock SqliteWriteConnectionMutex to become available
This instance runs the automation covered in the Telegram attendance tracker post among other workflows.
Why SQLite Broke
n8n defaults to SQLite. SQLite handles concurrent readers well but has a single write mutex: one writer at a time, everyone else queues. n8n wraps every execution state change in a write - starting, updating, finishing. A workflow receiving bursts of simultaneous webhook requests turns those writes into a traffic jam. Writers wait for the lock, hit the timeout, and fail with LockAcquireTimeoutError. Failed executions get retried, adding more load, and the queue grows.
SQLite is fine for low-volume n8n. It is not fine for webhook-driven workflows that get hit concurrently. That was our setup, and the symptoms above are what happens.
The Fix
Move n8n to PostgreSQL. We already run production Postgres instances, so the plan was: export from SQLite, point n8n at a new n8n database, import, verify.
It worked, but the export and import tools leave significant gaps. The migration took an evening, and about half of it was manual database work that the documentation does not cover. That half is the reason for this post.
Step 1: Clear the Stuck Executions
Before exporting, the lock pressure had to be relieved, because the stuck executions were the load. We cancelled every stuck run for the offending workflow directly in SQLite:
UPDATE execution_entity
SET status='canceled', finished=1, stoppedAt=datetime('now')
WHERE workflowId='<workflow-id>'
AND status IN ('running', 'new')
AND startedAt < datetime('now', '-2 minutes');
339 executions cleared in one statement.
Step 2: Export from SQLite
n8n’s CLI exports workflows and credentials to JSON:
docker exec n8n-n8n-1 n8n export:workflow --backup --output=/home/node/.n8n/migration-workflows.json
docker exec n8n-n8n-1 n8n export:credentials --backup --output=/home/node/.n8n/migration-credentials.json
That produced 76 workflows and 49 credentials. We also copied the SQLite file itself (6.1GB) to a backup location as a rollback path, and deleted it after verification.
Step 3: Point n8n at Postgres
A new empty n8n database on the production Postgres instance, then the container config:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres.example.com
- DB_POSTGRESDB_PORT=5434
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=postgres
- DB_POSTGRESDB_PASSWORD=<password>
On first boot with DB_TYPE=postgresdb, n8n runs its own schema migrations and creates all tables. No manual schema work.
Two unrelated long-standing bugs surfaced during this switch and got fixed at the same time:
N8N_HOST was localhost. n8n was rejecting frontend requests whose Host header did not match. Requests through the public domain failed with a bare “Cannot GET /”. Setting N8N_HOST to the real domain fixed it.
N8N_DIAGNOSTICS_ENABLED was on. n8n’s telemetry calls to Rudderstack were hitting 429 rate limits after repeated restarts, and the failing /identify calls blocked the UI from loading after login. Turning diagnostics off fixed it.
Both of these were cosmetic compared to the lock problem but had been annoying for months.
Step 4: Import, and Discover What the Import Misses
Import is the reverse of export:
docker exec n8n-n8n-1 n8n import:workflow --separate --input=/home/node/.n8n/migration-workflows.json
docker exec n8n-n8n-1 n8n import:credentials --separate --input=/home/node/.n8n/migration-credentials.json
Workflows and credentials came in. Four things did not:
User accounts. The export tool does not touch users. On first boot n8n creates an empty owner row with no email, and your actual login does not exist. We inserted the user directly:
INSERT INTO "user" (id, email, "firstName", "lastName", password, "roleSlug", disabled, "mfaEnabled", "createdAt", "updatedAt")
VALUES (
gen_random_uuid(),
'owner@example.com',
'John', 'Doe',
'<bcrypt-hash-from-the-old-database>',
'global:owner', false, false, NOW(), NOW()
);
The password hash came straight from the old SQLite user table, so the existing login kept working. Then the ghost row n8n created on first boot gets deleted:
DELETE FROM "user" WHERE email IS NULL OR email = '';
The personal project. n8n requires every user to have a personal project, and it is not migrated:
INSERT INTO project (id, name, type, "createdAt", "updatedAt", "creatorId")
VALUES (gen_random_uuid()::varchar, 'John Doe', 'personal', NOW(), NOW(), '<user-id>');
INSERT INTO project_relation ("projectId", "userId", role, "createdAt", "updatedAt")
VALUES ('<project-id>', '<user-id>', 'project:personalOwner', NOW(), NOW());
Project assignments. Imported workflows and credentials are not linked to any project. They show up as unmanaged and are not visible where you expect them:
INSERT INTO shared_workflow ("workflowId", "projectId", role, "createdAt", "updatedAt")
SELECT id, '<project-id>', 'workflow:owner', NOW(), NOW()
FROM workflow_entity ON CONFLICT DO NOTHING;
INSERT INTO shared_credentials ("credentialsId", "projectId", role, "createdAt", "updatedAt")
SELECT id, '<project-id>', 'credential:owner', NOW(), NOW()
FROM credentials_entity ON CONFLICT DO NOTHING;
Active states. Import deactivates every workflow. Before the migration, 44 of the 76 workflows were active. Setting active=true directly in the database is not enough - n8n does not pick it up. The only reliable way is the CLI, one workflow at a time:
docker exec n8n-n8n-1 n8n update:workflow --id=<id> --active=true
44 CLI calls later, the state matched.
Verification
The final check compared the old SQLite state against the new Postgres state row for row:
| SQLite | Postgres | |
|---|---|---|
| Total workflows | 76 | 76 |
| Active | 44 | 44 |
| Archived | 17 | 17 |
| Credentials | 49 | 49 |
| Users | 1 | 1 |
Everything matched. Only then did we delete the 6.1GB SQLite backup. The old SQLite volume was left in place as a passive fallback.
Lessons
- n8n’s export and import do not migrate users, project assignments, or active states. Budget for manual SQL and CLI work.
active=truein the database is not enough; usen8n update:workflow --active=trueand restart.N8N_HOSTmust match the public domain, notlocalhost.- SQLite is fine for low-volume n8n and breaks under concurrent webhook load. Use Postgres from the start for production.
The instance has run on Postgres since the migration with no lock errors. The telemetry workflow that caused the outage now processes its webhook bursts cleanly.