Back to Article List

n8n backup and restore: Database, encryption key, workflows

n8n backup and restore: Database, encryption key, workflows - n8n backup and restore: Database, encryption key, workflows

The last full restore I did of our n8n instance wasn't after a crash, it was a move to a new server, and it still took three attempts. The first backup had the Postgres dump and nothing else, so every credential opened as garbage. The second had the dump and the .n8n folder but from a run two days apart, so half the workflows referenced credentials that didn't exist yet. The third one worked because by then I'd written down what a restore needs, which is the list this article starts with. Everything after that is how to collect those pieces on a schedule and how to prove they come back.

What an n8n restore needs

Four things, taken at roughly the same moment:

The database. Postgres or SQLite, it holds workflows, credentials (encrypted), users, tags, settings, the executions you haven't pruned and the list of installed community packages. Without it you have nothing.

The encryption key. Credentials are encrypted with it, and a database restored next to a different key is a database full of credentials you can see but can't open. The key lives either in your environment as N8N_ENCRYPTION_KEY or, if you never set that, in the file /home/node/.n8n/config inside the n8n_data volume, where n8n wrote a random one on first start.

The binary data directory, when the instance runs in filesystem mode (the regular-mode default). Attachments and generated files sit under N8N_BINARY_DATA_STORAGE_PATH, which defaults to binaryData/ inside the same .n8n directory. If you moved execution data itself out of the database with N8N_EXECUTION_DATA_STORAGE_MODE=filesystem, the N8N_STORAGE_PATH directory joins this list.

The compose file and the .env next to it. Not because n8n needs them, but because you do, at 2am, on a server that doesn't have them.

Redis is not on the list. In queue mode it holds jobs that are waiting or in flight, and after a restore those are gone, which is a fact to accept and note in the runbook, not something a backup fixes. The queue mode with Redis workers guide goes into turning on AOF persistence so a Redis restart alone doesn't drop the queue. In regular mode there is no Redis and nothing to think about here.

Back up the n8n database

PostgreSQL with pg_dump

Run pg_dump inside the Postgres container and write the output to the host. The custom format (-Fc) is compressed and lets pg_restore pick tables later:

docker compose exec -T postgres pg_dump -U n8n -Fc n8n > /backup/n8n-$(date +%F).dump

Replace postgres with your service name and n8n with the user and database from your compose file. The -T flag stops compose from allocating a TTY, which matters when the command runs from cron. The pg_dump reference covers the format options if you want plain SQL instead. A dump of a database with a few days of executions in it is tens of megabytes; a dump of one where nobody set up execution pruning can be several gigabytes and is mostly JSON payloads you will never read, so fix that first.

SQLite

The database is a single file, database.sqlite, in the n8n_data volume. Copying it while n8n is writing gives you a file that may not open, so either stop the container for the few seconds the copy takes:

docker compose stop n8n
cp /var/lib/docker/volumes/n8n_n8n_data/_data/database.sqlite /backup/database-$(date +%F).sqlite
docker compose start n8n

Or use the sqlite3 CLI's online backup command, which takes a consistent snapshot without stopping anything:

sqlite3 /var/lib/docker/volumes/n8n_n8n_data/_data/database.sqlite ".backup /backup/database-$(date +%F).sqlite"

Your volume path comes from docker volume inspect n8n_n8n_data; the name depends on the compose project. Since 2.0 SQLite runs in WAL mode, so next to the main file you'll see database.sqlite-wal and -shm files. The .backup command folds those in for you. A plain cp of just the main file while the container is running does not. If you're still on SQLite with production traffic, the PostgreSQL vs SQLite comparison for n8n is the argument for moving, and backups are one of the reasons. The plain file copy above is fine for a one-off before an upgrade.

Back up the encryption key

Set the key explicitly. If your compose has N8N_ENCRYPTION_KEY=... in it (or in .env), the key is already in the fourth item on the list and the config file inside the volume just mirrors it. If you never set it, get it out of the container now:

docker compose exec -u node n8n cat /home/node/.n8n/config

That prints a small JSON with an encryptionKey field. Put the value in your .env as N8N_ENCRYPTION_KEY, restart, and from then on the file and the env agree. Do not change the value while the database has credentials in it, because n8n refuses to start when the two disagree. The official page on setting a custom key is short and says the same, and the encryption key rotation guide covers the supported way to move to a new key later. That is a separate procedure from a backup and it needs the old key present to work.

Keep a copy of the key somewhere that is not the backup archive. A password manager entry is enough. The archive is encrypted at rest by restic in the setup below, but "the key to the credentials is in the same tarball as the credentials" is a sentence you don't want to say to an auditor.

Binary data and the .n8n folder

The simplest approach is to tar the whole n8n_data volume, which picks up the config file, the binary data directory, the SQLite file if you use one and the nodes/ folder where community packages are installed. Use a throwaway container so the paths and ownership come out right:

docker run --rm \
  -v n8n_n8n_data:/data:ro \
  -v /backup:/backup \
  alpine tar czf /backup/n8n_data-$(date +%F).tgz -C /data .

If binary data is large (video, scanned documents) split it out into its own bind mount and back it up separately with a tool that does deduplication, otherwise every nightly tarball repeats the same gigabytes. On our box the whole volume is 180 MB with two weeks of attachments, so I don't bother.

Export workflows and credentials with the n8n CLI

The database dump is the restore path. The CLI export is the second copy, and it's the one I open more often, because a workflow JSON in a folder is something you can diff, grep and read at 2am. In 2.x both commands run through docker compose exec as the node user:

docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/backup/workflows/
docker compose exec -u node n8n n8n export:credentials --backup --output=/home/node/.n8n/backup/credentials/

--backup is shorthand for "all of them, one file each, pretty printed", which is the shape you want under version control. Credentials come out encrypted with the instance key, so the files are safe to store next to the workflows and useless to anyone without the key. There is also --all --decrypted, which writes the secrets in plain text. It has a real use (moving credentials to an instance with a different key) and it should never be in a scheduled job; if you run it, run it by hand, encrypt the output with age or gpg straight away and delete the plain file. The complete flag list is on the n8n CLI reference, including the newer export:entities / import:entities pair that dumps every table to a directory and is the closest thing to an application-level full backup. I haven't needed the entities pair yet; the two exports above plus pg_dump cover everything I've had to restore.

Restoring an export goes through the matching import commands. --separate is the flag for a directory of one-file-per-workflow:

docker compose exec -u node n8n n8n import:credentials --separate --input=/home/node/.n8n/backup/credentials/
docker compose exec -u node n8n n8n import:workflow --separate --input=/home/node/.n8n/backup/workflows/

Credentials first, then workflows, so the workflow import can resolve the credential references. Imported workflows come in unpublished; n8n publish:workflow --id=<ID> brings each one live (the older update:workflow --active=true still works but is deprecated and goes away in a future release). There is no --all for publish, so a restore of forty workflows is forty commands or a loop over the IDs.

Git-backed source control inside n8n is a Business and Enterprise feature, and the n8n version control and environments article covers what it does and what to do without it. On the community edition the exported directory committed to a repo is your workflow history, nightly, from cron.

A cron backup script with pg_dump, tar and restic

This is the script that runs on our instance at 03:10, minus the Slack notification. It assumes Postgres, a compose project in /opt/n8n and a restic repository on a separate machine reached over SFTP:

#!/usr/bin/env bash
set -euo pipefail

cd /opt/n8n
STAMP=$(date +%F-%H%M)
WORK=/backup/n8n/$STAMP
mkdir -p "$WORK"

# 1. database
docker compose exec -T postgres pg_dump -U n8n -Fc n8n > "$WORK/n8n.dump"

# 2. the n8n_data volume: config (encryption key), binaryData, community nodes
docker run --rm -v n8n_n8n_data:/data:ro -v "$WORK":/backup alpine \
  tar czf /backup/n8n_data.tgz -C /data .

# 3. workflow and credential exports, readable copies
docker compose exec -T -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/export/workflows/ > /dev/null
docker compose exec -T -u node n8n n8n export:credentials --backup --output=/home/node/.n8n/export/credentials/ > /dev/null
docker cp "$(docker compose ps -q n8n)":/home/node/.n8n/export "$WORK/export"

# 4. compose file and env
cp compose.yaml .env "$WORK/"

# 5. off the server
export RESTIC_REPOSITORY=sftp:[email protected]:/srv/restic/n8n
export RESTIC_PASSWORD_FILE=/root/.restic-n8n
restic backup "$WORK" --tag n8n
restic forget --tag n8n --keep-daily 14 --keep-weekly 8 --keep-monthly 6 --prune

rm -rf "$WORK"

And the crontab line:

10 3 * * * /usr/local/bin/n8n-backup.sh >> /var/log/n8n-backup.log 2>&1

restic encrypts the repository, deduplicates across snapshots and the forget line gives you two weeks of dailies, two months of weeklies and half a year of monthlies. The restic documentation lists the other backends: S3 and anything S3-compatible, Backblaze B2, rclone for the rest. If you'd rather keep the target under your own control, a storage VPS with HDD capacity in a different city than the n8n server works as the SFTP endpoint and costs less per gigabyte than NVMe you'd otherwise fill with tarballs. Whatever the target, it has to be a different machine than the one running n8n.

Two small things bit me with this script. docker compose exec without -T hangs under cron waiting for a terminal, and the export commands print a success line to stdout that ends up in the log every night, which is why the redirect is there. Neither is dangerous.. they're just the kind of thing you find out at the first run.

Restore drill, step by step

On a fresh server with Docker installed:

1. Restore the restic snapshot to a working directory: restic restore latest --target /restore.

2. Copy compose.yaml and .env into /opt/n8n. Open .env and confirm N8N_ENCRYPTION_KEY is there and matches the value in your password manager. Same key or nothing decrypts; there is no step later that recovers from getting this wrong.

3. Start Postgres only: docker compose up -d postgres, then load the dump:

docker compose exec -T postgres pg_restore -U n8n -d n8n --clean --if-exists < /restore/backup/n8n/<stamp>/n8n.dump

4. Recreate the volume from the tarball before n8n starts, so it doesn't generate a new config file:

docker volume create n8n_n8n_data
docker run --rm -v n8n_n8n_data:/data -v /restore/backup/n8n/<stamp>:/backup alpine \
  tar xzf /backup/n8n_data.tgz -C /data

5. docker compose up -d, then docker compose logs -f n8n until you see the migrations finish and the editor URL printed. A "Mismatching encryption keys" line here means step 2 went wrong.

6. Log in, open a credential and press the test button. Open a workflow with a Webhook node and check the production URL shows the right domain (that's N8N_WEBHOOK_URL from .env, and a DNS change to the new server's IP if this is a real migration). Fire one webhook by hand.

7. Check the published state of your workflows. A database restore brings back the published flags as they were at dump time, so anything you published after the dump is unpublished on the restored instance.

The drill takes me around 25 minutes on a two vCPU VPS, most of it waiting on pg_restore and DNS. The number is only useful if you measure your own, which is the point of doing it on a server that isn't production.

RPO and RTO for a single n8n instance

With a nightly dump your recovery point is up to 24 hours old; run the script every six hours if losing a day of workflow edits and execution history is unacceptable, since the dump is cheap. Recovery time is the drill above, so for us it's under an hour including the time to notice, and I'd rather have that number written down than a vague feeling that it's quick.

The advice is to test the restore quarterly. I do it twice a year, once in January and once when something else makes me rebuild a server anyway, and I'm aware that's below the bar I'd set for someone else. The reason it slips is that the restore drill has passed every time since I wrote the script, which is a bad reason and I know it.

Automate faster, for less

Bring your winning ideas to life with AMD power, NVMe speed and unmetered bandwidth.