Self-Hosted n8n Production Checklist: Backups, Updates

For production self-hosted n8n, back up three things: the .n8n folder (it holds the credential encryption key), a Postgres dump, and your compose file and .env. To update, back up, change the pinned version, run docker compose pull and docker compose up -d. Roll back by restoring the backup, not the tag. Monitor /healthz/readiness from outside the server.

Installing n8n takes an evening. Keeping it running for a year without losing a credential, missing a failed workflow or getting stuck on a bad update is the real job. I've set up self-hosted n8n for clients on Oracle Cloud's free tier and on Hostinger VPS plans. This is the checklist for everything after the install.

Every n8n command and setting here comes from n8n's docs, and the Docker, PostgreSQL and other tool commands come from their own docs. I link them where they come up and list them all at the end. Where I combine them into my own script or command, or make a call the docs don't, I say so.

Still need a server? Start with my Oracle Cloud free tier guide or the Hostinger VPS guide. If you set up n8n from either guide before this checklist existed, check three settings covered below: an exact image version instead of an unpinned one, port 5678 bound to 127.0.0.1, and N8N_WEBHOOK_URL in place of WEBHOOK_URL once you're on n8n 2.35.0 or later.

The self-hosted n8n production checklist

Six areas, in the same order as my production setup service. Each one links to the section that shows how.

Back up the n8n encryption key first

n8n encrypts credentials before saving them to the database. On first launch it generates a random key and stores it in a file called config inside the .n8n folder, which in Docker is /home/node/.n8n.

n8n's backup and restore page is blunt about it: "Without the encryption key from the config file, or a custom N8N_ENCRYPTION_KEY, a restored database or encrypted credential export can't be decrypted." A perfect database backup without the key leaves every API key and OAuth connection unreadable.

Get the key into your password manager today. First copy the file out of the container. This is Docker's compose cp command pointed at the path n8n documents:

cd /opt/n8n   # the folder that holds your compose file
docker compose cp n8n:/home/node/.n8n/config ./n8n-config.json

The file is JSON with an encryptionKey field. Put that value in your password manager, away from the server, then delete the local copy with rm n8n-config.json.

Then pin it. Set N8N_ENCRYPTION_KEY to that exact value. Put it in .env if your n8n service has env_file: .env (n8n's current Compose file does). Otherwise, add it to the n8n service's environment: block. My quick check, which doesn't print the key: docker compose exec n8n sh -c 'test -n "$N8N_ENCRYPTION_KEY" && echo set' prints set when the variable reached the container.

On a brand-new instance, you can generate a key before the first start with openssl rand, for example openssl rand -base64 32.

Fix "Mismatching encryption keys"

Never invent a new key for an instance that already has credentials. n8n's source code compares the two keys at startup and refuses to start with an error that reads "Mismatching encryption keys. The encryption key in the settings file ... does not match the N8N_ENCRYPTION_KEY env var." The fix: set N8N_ENCRYPTION_KEY to the encryptionKey value in /home/node/.n8n/config, or remove the variable so n8n reads the key from that file.

What to back up in self-hosted n8n (SQLite vs Postgres)

n8n defines a complete backup as the .n8n folder plus your external database. I add the deployment config, because n8n's restore steps need "the environment variables for your database connection, a custom N8N_ENCRYPTION_KEY if you use one, and any external storage settings."

Piece Why SQLite Postgres
.n8n folder (Docker volume) Encryption key, plus binary data in filesystem mode Yes, it also holds database.sqlite Yes, for the key
Database Workflows, credentials, users, settings, executions Inside .n8n pg_dump
Compose file and .env Database connection settings and your pinned key Yes Yes
Custom nodes, external storage (S3, Azure Blob) Workflows depend on them If used If used

SQLite: stop n8n before you copy. Copying the file while n8n writes to it "can produce an inconsistent backup," per the docs. Since n8n 2.0, the SQLite driver uses WAL mode, which keeps a separate -wal file beside the database, so a live copy of database.sqlite alone can miss data. Better still, move to Postgres. n8n's Docker Compose guide says "SQLite is fine for trying things out," but production instances running around the clock should use Postgres. Old data doesn't carry over by itself. The CLI's export:entities and import:entities commands handle the move.

Postgres: dump it live. The PostgreSQL manual says pg_dump "makes consistent exports even if the database is being used concurrently" without blocking readers or writers. It also says that except in simple cases, pg_dump "is generally not the right choice for taking regular backups of production databases." I treat one n8n database on one VPS as that simple case, and a nightly dump as enough for it. A large, busy database should look at continuous archiving instead.

Keep Postgres pinned to a major tag, like the postgres:18 in n8n's example, and don't bump it (16 to 18, say) as part of an n8n update. n8n's docs warn that bumping the tag on an existing setup fails with database files are incompatible with server.

Export workflows and credentials with the n8n CLI

The CLI gives you a second, readable copy. The documented commands:

n8n export:workflow --backup --output=backups/workflows/
n8n export:credentials --backup --output=backups/credentials/

--backup sets --all --pretty --separate: one JSON file per workflow and per credential. Use separate folders. n8n lists a known issue where import:credentials --separate fails if workflow files share the folder.

In Docker, n8n documents docker exec -u node -it <n8n-container-name> <n8n-cli-command>. With Compose, prefix commands with docker compose exec -u node n8n. The gotcha: "a backups/ directory inside the container isn't persisted." Bind-mount a host folder or copy the files out before the next update recreates the container.

What the CLI backup misses: users and roles, execution history, variables and instance settings, including the encryption key. In n8n's words, it is "enough to move workflows between instances, but not enough to recover a full instance on its own." The credential files stay encrypted with your key.

Backup templates are the same kind of copy. n8n's template library has workflows like Back up your n8n workflows to Github, which "uses the public api to export all of the workflow data," and one that runs the n8n CLI over SSH inside the Docker container. Neither saves the encryption key, users or execution history. They sit next to a full backup, not in place of it.

The --decrypted flag writes every credential in plain text, for migrating to an instance with a different key:

n8n export:credentials --all --decrypted --output=backups/decrypted.json

The CLI docs warn: "All sensitive information is visible in the files." Delete it once the restore is done. Never put it in a scheduled job or an off-site bucket.

Restore with n8n import:workflow --separate --input=backups/workflows/ and the matching credentials command. On a fresh instance, finish the owner setup first, because imports need an existing user. Imports overwrite items with the same IDs, and workflows arrive deactivated.

Automate n8n backups with a cron script

Here's a nightly script I put together for this guide from the documented commands above. It isn't an official n8n procedure, so run it by hand once and test a restore before you schedule it. It assumes Compose lives in /opt/n8n with services named n8n and postgres, POSTGRES_USER and POSTGRES_DB set as in n8n's Postgres example, and rclone installed with an encrypted remote (set up below).

#!/usr/bin/env bash
set -euo pipefail
umask 077                          # new backup files readable by root only

PROJECT_DIR=/opt/n8n               # the folder with your compose file
COMPOSE_FILE_NAME=compose.yml      # or compose.yaml / docker-compose.yml
N8N_VOLUME=n8n_n8n-data            # check yours with: docker volume ls
OFFSITE=n8n-crypt:                 # your rclone crypt remote
KEEP_DAYS=14                       # local backups to keep

cd "$PROJECT_DIR"
STAMP=$(date +%F-%H%M)
DEST=/var/backups/n8n/$STAMP
mkdir -p "$DEST"

# 1. Database dump, taken while n8n keeps running
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > "$DEST/n8n-db.dump"

# 2. The .n8n folder: encryption key, plus filesystem binary data
docker volume inspect "$N8N_VOLUME" > /dev/null     # stop if the volume name is wrong
docker run --rm -v "$N8N_VOLUME":/data:ro -v "$DEST":/backup ubuntu \
  tar cf /backup/n8n-data.tar -C /data . || [ $? -eq 1 ]
tar tf "$DEST/n8n-data.tar" ./config > /dev/null     # stop if the key file is missing

# 3. Deployment config for the restore (contains secrets)
cp "$COMPOSE_FILE_NAME" "$DEST/"
cp .env "$DEST/"                   # delete this line if you have no .env

# 4. Readable CLI exports, copied out of the container
docker compose exec -T -u node n8n n8n export:workflow --backup --output=/tmp/export/workflows/
docker compose exec -T -u node n8n n8n export:credentials --backup --output=/tmp/export/credentials/
docker compose cp n8n:/tmp/export "$DEST/export"
docker compose exec -T -u node n8n rm -rf /tmp/export

# 5. Encrypted off-site copy, then local retention
rclone copy "$DEST" "$OFFSITE$STAMP"
find /var/backups/n8n -mindepth 1 -maxdepth 1 -type d -mtime +"$KEEP_DAYS" -exec rm -rf {} +

# 6. Heartbeat, only reached if every step above succeeded
curl -fsS -m 10 --retry 5 -o /dev/null https://hc-ping.com/your-uuid-here

Save it as /opt/n8n/backup.sh, lock it down and run it once by hand:

sudo chmod 700 /opt/n8n/backup.sh
sudo /opt/n8n/backup.sh

If it finishes and your heartbeat check shows a ping, schedule it. The script needs Docker access, so it goes in root's crontab: open it with sudo crontab -e and add this line to run it at 03:15 every night (fields per the crontab format):

15 3 * * * /opt/n8n/backup.sh >> /var/log/n8n-backup.log 2>&1

What each guard in the script is for:

Off-site, and encrypted. A backup on the same disk dies with the disk, so step 5 sends each night's folder away with rclone copy. Each folder holds the encryption key (in the tar and in .env) right next to the encrypted credentials, and together they open everything, so point step 5 at an rclone crypt remote: "Rclone crypt remotes encrypt and decrypt other remotes." Set up the storage remote, and the crypt remote on top of it, with rclone config as root (for example after sudo -i). Root runs the script, and rclone keeps its config per user. Keep the crypt password in your password manager next to the n8n key.

rclone copy "Doesn't delete files from the destination," so the remote grows until you prune it. rclone delete --min-age 30d n8n-crypt: removes files older than 30 days, and --rmdirs clears the empty folders. Locally, the find line in step 5 deletes backup folders older than KEEP_DAYS.

On SQLite, delete step 1 and move step 4 above step 2, because the exports need n8n running. Then wrap the step 2 commands in a stop and a start:

docker compose stop n8n
trap 'docker compose start n8n' EXIT   # restarts n8n even if the copy fails
# ...step 2 here: the tar now includes database.sqlite
docker compose start n8n
trap - EXIT

Restore n8n from a backup (and test the restore)

A backup you haven't restored is a guess. n8n's documented restore has five steps: stop n8n, restore the .n8n folder (plus any custom-node directories), restore the Postgres database, restore the deployment configuration, start n8n. Below is my version of those steps for the script above. It isn't an official n8n procedure, so run it on a throwaway server before you rely on it.

In place, on the same server (this is also the rollback). Run it as root (for example after sudo -i), because the backup folders are root-only:

cd /opt/n8n
BACKUP=/var/backups/n8n/2026-09-19-0315    # the folder to restore
N8N_VOLUME=n8n_n8n-data

docker compose stop                        # n8n, workers and Postgres
cp "$BACKUP/compose.yml" "$BACKUP/.env" ./ # old settings, including N8N_VERSION
docker compose up -d --wait postgres       # start only the database

# Drop the database and recreate it from the dump
docker compose exec -T postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d postgres --clean --if-exists --create' < "$BACKUP/n8n-db.dump"

# Empty the .n8n volume, then extract the backup into it
docker run --rm -v "$N8N_VOLUME":/data -v "$BACKUP":/backup ubuntu \
  sh -c 'find /data -mindepth 1 -delete && tar xf /backup/n8n-data.tar -C /data'

docker compose up -d

Two details in there matter:

Use your compose file's real name in the cp line, and drop .env if you don't have one. On SQLite, skip the two Postgres steps: the database comes back with the volume. Copying the old .env back also puts the old N8N_VERSION back, which is what a rollback needs. --wait holds until Postgres reports healthy, which works because n8n's Postgres example defines a healthcheck (docker compose up).

On a fresh test server:

  1. Install Docker and create the same folder, /opt/n8n, so Compose derives the same project and volume names. Copy in the compose file, .env and one backup folder.
  2. In the test .env, point N8N_WEBHOOK_URL (or WEBHOOK_URL) at a test hostname.
  3. Run docker compose up --no-start. It creates the volumes and containers without starting anything.
  4. Run the up -d --wait postgres, database and volume commands from the block above.
  5. Before n8n starts for the first time, unpublish every workflow: docker compose run --rm n8n unpublish:workflow --all.
  6. Run docker compose up -d, log in with your production owner account and test a few credentials. If they connect, the backup is real.

Don't skip step 5. A database restore keeps every workflow's published state, so a copy started as-is runs every schedule and polling trigger against real systems with working credentials: duplicate emails, CRM writes, alerts in your real Slack. n8n's CLI docs say the publish and unpublish commands "operate on your n8n database," the image's entrypoint passes arguments straight to n8n, and docker compose run doesn't publish the service's ports. That one-off command is still my composition, so check the workflow list after the first start before you touch anything.

How to update self-hosted n8n in Docker Compose

Updating the n8n version on a self-hosted Community edition instance works the same on any VPS. n8n's update page says "Try to update at least once a month," because jumping many versions at once is riskier.

Pin an exact version. The n8n 2.0 notes say "Pin your n8n version to a specific version number". The official Compose file does this with image: docker.io/n8nio/n8n:${N8N_VERSION} and the number in .env:

N8N_VERSION=2.39.8

That was the stable release at the time of writing (September 2026). Get the current one from GitHub releases. If your image: line has no tag, like image: n8nio/n8n, change it to docker.io/n8nio/n8n:${N8N_VERSION} and add the N8N_VERSION line to .env, or write the version straight into the image line. In n8n's current Compose file, N8N_VERSION also pins the task runners image, and a separate N8N_SANDBOX_VERSION pins the sandbox services.

Avoid floating tags like stable or latest in production. Nothing in the docs promises they won't jump to a new major version once n8n promotes one. Skip auto-updaters like Watchtower too. Its README now says "This project is no longer maintained," and its maintainers write that they "do not recommend using Watchtower in a commercial or production environment." An auto-updater also pulls the new version before anyone has read the release notes.

If you installed with n8n's one-line setup, use its upgrade command instead: curl -fsSL https://get.n8n.io | sh -s -- --upgrade. It "edits two lines in .env: N8N_VERSION and N8N_SANDBOX_VERSION," and --version plus a number picks a specific release. Run it from the folder you installed from (n8n's everyday commands for that setup point at ./n8n/compose.yml), and take the backup first.

Read the release notes. n8n follows semantic versioning, so majors carry "incompatible changes which can require user action." The release notes flag breaking changes.

Test on a copy. n8n suggests Environments for this, but Environments isn't in the free Community edition. What I'd do instead: restore last night's backup on a throwaway server, following the fresh-server steps above, and run the new tag there first.

Then update:

cd /opt/n8n
sudo /opt/n8n/backup.sh      # n8n recommends a full backup before updating
nano .env                    # set N8N_VERSION to the new number
docker compose pull
docker compose up -d
docker compose logs -f --tail=100 n8n

docker compose up recreates containers whose image changed, "preserving mounted volumes." n8n's older Docker page adds docker compose down between pull and up, which also works. Never run docker compose down -v: it deletes named volumes, key included. docker compose pull pulls every service's image, which is one more reason to keep Postgres on a major tag. The last line follows the logs. Afterwards, confirm /healthz/readiness returns 200 and run one real workflow.

Roll back a failed n8n update: restore, don't re-tag

n8n doesn't document downgrades as supported. Its 3.0 notes mention, while explaining its node development tooling, that "n8n only migrates a database forward." Key rotation is one-way as well: the rotation docs say "Older versions can't decrypt the new format." The rollback I recommend is the in-place restore with the backup you took right before the update: stop, put back the old compose file and .env, restore the dump and the .n8n volume, start.

Prepare for n8n 3.0 (scheduled for October 2026)

n8n has scheduled 3.0 for October 2026. Among the changes that hit self-hosters:

To prepare, read your 2.x startup logs (n8n logs a deprecation warning for each 3.0 configuration change that affects your instance) and open Settings > Migration Report. Then read the full 3.0 page for what those two don't cover, take a full backup and stay pinned until you've worked through it.

Keep the n8n database small with execution pruning

Without limits, "your n8n database can grow in size and run out of storage," say the execution data docs. Pruning is on by default: finished executions older than 336 hours (14 days) are deleted, and so are the oldest ones past 10,000. Annotated executions are never pruned. n8n's documented example for 7 days and 50,000 executions:

services:
  n8n:
    environment:
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
      - EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000

If your environment: block uses KEY: value lines, as n8n's Postgres example does, write these the same way. Mixing the two styles breaks the file. Or put them in .env if the service loads it.

EXECUTIONS_DATA_SAVE_ON_SUCCESS=none stops saving successful runs. Keep EXECUTIONS_DATA_SAVE_ON_ERROR=all (the default), because the execution link in your error alerts only exists for saved executions. On SQLite, pruned space isn't returned to the disk. DB_SQLITE_VACUUM_ON_STARTUP=true reclaims it, at the cost of a "long running blocking operation" on every start.

Monitor n8n: /healthz, /healthz/readiness and /metrics

n8n has three status endpoints:

Point uptime monitors at /healthz/readiness. /healthz can answer fine while Postgres is unreachable. On the server, n8n's install guide checks with curl -sf http://localhost:5678/healthz. Queue mode workers need QUEUE_HEALTH_CHECK_ACTIVE=true before they serve /healthz and /healthz/readiness.

For metrics, set N8N_METRICS=true. n8n's warning is plain: "Don't make it accessible on the public internet, as it can reveal sensitive operational data about your n8n instance." /metrics is served at your instance URL, right next to the editor. Block that path at the reverse proxy and let only Prometheus reach it.

Also watch the logs for "Allocation failed - JavaScript heap out of memory". n8n's memory docs list that message for self-hosted instances and cover the two ways out: give n8n more memory, or make it use less.

Get alerts when an n8n workflow fails

An automation that fails quietly is worse than none. n8n's answer is the Error Workflow:

  1. Create a workflow that starts with the Error Trigger node, named something like Error Handler.
  2. Add a Slack or email step with workflow.name, execution.error.message, execution.lastNodeExecuted and execution.url, so the alert links to the failed run.
  3. Save it. It doesn't need to be published.
  4. In each production workflow, open Options > Settings, choose it under Error workflow and save. One error workflow can serve them all.

Per the Error Trigger docs, it only fires when an automatic run fails. To test it, build a small workflow with a Schedule Trigger that ends in a Stop And Error node, select your Error Handler in its settings and publish it, since a Schedule Trigger only runs on a published workflow. Wait for one run, then unpublish it. If the trigger node itself fails, the payload has no execution ID or URL.

The blind spot: the Error Workflow runs inside n8n. If the server, Docker or Postgres is down, nothing fires. So add an outside check on /healthz/readiness:

One gap is left: a scheduled workflow that simply stops running raises no error, and the outside check stays green. For the few workflows that must run, end them with an HTTP Request node that pings a Healthchecks.io check, the same heartbeat the backup script uses. When a run doesn't happen, the missed ping alerts you.

When to switch n8n to queue mode

Queue mode has the main instance handle timers and webhooks and pass executions through Redis to separate workers. It's in the free Community edition, and the docs call it the mode with "the best scalability." Two things are requirements: no filesystem binary data, and the same encryption key on every worker. n8n also advises against SQLite in queue mode and recommends a worker concurrency of 5 or more. I wouldn't start a small team on it. Add it when one instance can't keep up.

Secure a self-hosted n8n instance

An earlier client setup

My self-hosted AI stack case study shows a 2025 client setup on Oracle Cloud's free tier: n8n on Postgres behind Caddy, with a runbook for updates and database backups. This checklist goes further than that build. The Community edition is free, but this list is the ops work you sign up for. My n8n pricing guide weighs it against n8n Cloud, or you can run your own numbers in my n8n cost calculator.

Want it set up for you?

My Production n8n setup covers the same six areas on a server you own: server, security, database, backups, updates and monitoring, with a written update routine. How much of the list above goes in depends on your server and volume, so I scope it and quote it in writing within 24 hours of a call. Details are on my n8n consultant page, or book a 30-minute call.

Sources

Versions and plan limits were checked in September 2026. n8n releases a new minor version most weeks. Check the current version and the 3.0 date before you act on them.

Frequently asked questions

What do I need to back up for self-hosted n8n?

Back up the .n8n folder, which holds the config file with the credential encryption key and, on SQLite, the whole database. If you run Postgres, add a pg_dump of the database. I also keep the compose file and .env, because a restore needs your database settings and any custom N8N_ENCRYPTION_KEY. Add custom node folders and external storage if you use them. CLI exports alone are not a full backup: they skip users, execution history and instance settings.

What happens if I lose the n8n encryption key?

Your stored credentials become unreadable. n8n encrypts credentials with that key before saving them, and its docs say a restored database or encrypted credential export can't be decrypted without it. You would have to re-enter every API key and OAuth connection by hand. Copy the key from /home/node/.n8n/config into a password manager and pin the same value as N8N_ENCRYPTION_KEY.

How do I update n8n in Docker Compose without losing data?

Pin the image to an exact version, such as n8nio/n8n:2.39.8 (in n8n's official Compose file, that's N8N_VERSION in .env). Read the release notes, take a full backup, change the version, then run docker compose pull and docker compose up -d. Compose recreates the n8n container and keeps mounted volumes, so your data stays. Never run docker compose down -v, which deletes named volumes. Check the logs and /healthz/readiness afterwards. n8n suggests updating at least once a month.

How do I roll back a failed n8n update?

Restore the backup you took right before updating. Stop the stack, put back the old compose file and .env (which also restores the old N8N_VERSION), recreate the database from the dump with pg_restore --clean --create, empty the .n8n volume and extract the backup into it, then start. Don't rely on changing the image tag alone: n8n doesn't document downgrades as supported, and once encryption key rotation is on, older versions can't decrypt the new format.

What changes in n8n 3.0 for self-hosted instances?

n8n has scheduled 3.0 for October 2026. For self-hosters, Docker becomes the only supported install, ~/.n8n/binaryData is renamed to ~/.n8n/storage on first start, legacy nodes such as Function, Cron and Interval are removed, installing unverified community nodes is off by default, the task runner timeout for Code node tasks drops from 300 to 60 seconds, and key rotation is listed as on by default. Check Settings > Migration Report and take a full backup before you upgrade.

How do I get alerts when an n8n workflow fails?

Create a workflow that starts with the Error Trigger node and sends a Slack or email message, then select it under Error workflow in each workflow's settings. It fires only when an automatic execution fails, not on manual test runs. Because it runs inside n8n, it can't tell you when n8n itself is down, so add an external uptime check on /healthz/readiness as well.