On this page

A workflow server needs to survive a bad update or a lost Droplet. Keep its credentials, execution history, and configuration in an encrypted backup, then restore it somewhere safe.

See n8n’s full-backup requirements and Spaces access scopes. The revised cleanup logic has offline failure-injection coverage only. It removes temporary archives on handled failures; deletion is not a secure-erasure guarantee, and power loss or SIGKILL can leave files behind. A complete live restore remains a separate required rehearsal.

The command blocks below target the n8n 2.x Server CLI. Check the recorded image’s --version and unpublish:workflow --help before continuing. The current Server CLI reference documents entity export/import and unpublish:workflow --all; update:workflow is deprecated from 2.0. If the recorded image is older or lacks a shown command, stop and adapt against that version’s help, without upgrading during recovery. No installed n8n runtime was exercised in this review.

Decide what recovery means before the first workflow

n8n stores workflow definitions, execution data, configuration, and credentials encrypted with its instance key. A canvas screenshot is not a backup. Recovery is successful when a fresh server starts with the same encryption key, loads a chosen workflow, and completes a harmless test without reconnecting every service.

This guide uses DigitalOcean's n8n 1-Click App on one Droplet and a private Spaces bucket for encrypted backups. There is no failover here. Keep customer records, production API keys, and financial actions out of workflows until you have tested normal failure handling and this recovery process.

  • A team-owned domain such as n8n.example.com and access to its DNS records.
  • A DigitalOcean account that can create a Droplet, firewall, Spaces bucket, and scoped Spaces key.
  • A Linux administrator who can use SSH and can keep a recovery key offline.
  • A separate fresh test Droplet for the restore test.

Deploy n8n, then lock down access

The Marketplace app creates a Droplet with the current stable n8n release. Point a DNS A record at that server before you finish setup. Use SSH-key login for the administrator. Do not turn on password SSH to make the first login easier.

Create a cloud firewall before anyone else can reach the server. Allow TCP 80 and 443 for the web service. Allow TCP 22 only from your VPN exit or fixed administrative IP. Enable Droplet monitoring and backups, but treat them as a separate recovery layer. This guide creates a portable backup of n8n data and configuration.

Shell command
ssh ADMIN_USER@YOUR_DROPLET_IP
cd /opt/n8n-docker-caddy
sudo docker compose ps
sudo docker compose config --services
N8N_CONTAINER=$(sudo docker compose ps -q n8n)
N8N_IMAGE_ID=$(sudo docker inspect --format '{{.Image}}' "$N8N_CONTAINER")
sudo docker image inspect --format '{{json .RepoDigests}}' "$N8N_IMAGE_ID"
sudo docker volume ls
sudo ls -la

Keep the callback address and encryption key fixed

Finish the initial setup at your HTTPS domain. Do not keep using the server IP in bookmarks. Inspect the generated Compose and environment files before you edit them. Set the public host, protocol, and webhook URL to the final HTTPS address, using the variable names for the installed n8n version. Save a protected copy of the current configuration before you restart the stack.

In the standard Docker and Caddy layout, the n8n data volume holds the SQLite database and its encryption material. That archive recovers credentials. If N8N_ENCRYPTION_KEY lives outside the volume, archive the protected configuration as well. Rotate a key as planned maintenance, never as a troubleshooting step. A backup with the wrong key can show credential records that n8n cannot use.

  • Keep secrets in the server environment or a protected secrets manager, never in a workflow note or Git repository.
  • Use a named owner for each production credential and remove credentials that no workflow uses.
  • Set execution pruning before the archive exceeds the backup window or your approved storage budget.

Create a private backup bucket and a dedicated key

Create a private Spaces bucket such as n8n-recovery-your-team in the region you intend to use. Do not enable public listing or a CDN. Turn on versioning and set a lifecycle policy that keeps archives for the period your team approves. Ninety days is a sensible starting point when no recovery requirement exists. Create a separate Spaces key limited to that bucket. DigitalOcean limited keys offer Read or Read/Write/Delete access. The upload job needs Read/Write/Delete because Spaces has no narrower write-only key. Store it in a root-readable file on the Droplet with mode 600. Put the age recipient public key beside it. Keep the matching private identity off the Droplet, for example in a team password manager with recovery access.

An archive in Spaces cannot be read without the offline age identity. That helps if the Spaces key leaks. It also means the recovery owner must prove they can use the identity before the backup job counts.

On your administrator workstation, install age and create the identity with age-keygen -o n8n-recovery-key.txt. Protect that file in your password manager. Run age-keygen -y n8n-recovery-key.txt to display the public age1... recipient to copy to the Droplet. Keep the private identity off the production server.

Shell command
sudo apt-get update
sudo apt-get install -y awscli age
sudo install -d -m 700 /etc/n8n-recovery
sudo nano /etc/n8n-recovery/spaces.env
# Add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then save.
sudo chmod 600 /etc/n8n-recovery/spaces.env
sudo nano /etc/n8n-recovery/age-recipient.txt
# Add one age1... recipient public key, then save.
sudo chmod 600 /etc/n8n-recovery/age-recipient.txt

Install age

Back up the persistent volume and the Compose file

This procedure covers SQLite with its encryption key in the .n8n volume config file and local files under APP_DIR/local_files. Caddyfile may be at the root or under caddy_config. If N8N_ENCRYPTION_KEY comes from the environment, or a persistent bind mount is outside APP_DIR, adapt and test the backup before continuing. Record the exact image digest with docker inspect and keep it with the recovery set.

The 1-Click App's Compose layout can change. Do not copy a volume name from this article. Run docker volume ls and inspect the Compose configuration you recorded earlier to find the volume holding n8n data. This guide covers the Marketplace Docker and Caddy layout with persistent n8n volume data. If you replace it with PostgreSQL, use the database vendor's dump and restore procedure and test it as its own recovery plan.

The script briefly stops the Compose stack, archives the named n8n volume and deployment configuration, encrypts both with the public age recipient, and uploads the encrypted files. Set VOLUME_NAME only after you inspect the server. The script refuses to guess. It includes only configuration files that exist, adds the Caddy configuration when present, and fails without a Compose file. Run it by hand before you schedule it.

Save the script below as /usr/local/sbin/backup-n8n-to-spaces with the server editor. Then run the installation commands and first backup before creating the schedule.

/usr/local/sbin/backup-n8n-to-spaces
#!/usr/bin/env bash
set -euo pipefail
umask 077

APP_DIR=/opt/n8n-docker-caddy
VOLUME_NAME=REPLACE_WITH_THE_N8N_DATA_VOLUME
BUCKET=YOUR_PRIVATE_BUCKET
REGION=YOUR_SPACES_REGION
PREFIX=n8n
BACKUP_DIR=/var/backups/n8n

case "$VOLUME_NAME" in
  REPLACE_*) echo "Set VOLUME_NAME after docker volume ls" >&2; exit 1 ;;
esac

set -a
. /etc/n8n-recovery/spaces.env
set +a
export AWS_DEFAULT_REGION=us-east-1
install -d -m 700 "$BACKUP_DIR"
WORK_DIR=$(mktemp -d "$BACKUP_DIR/run.XXXXXXXX")
RESTART_REQUIRED=0
cleanup() {
  status=$?
  trap - EXIT HUP INT TERM
  set +e
  if (( RESTART_REQUIRED )); then
    sudo docker compose start
    restart_status=$?
    if (( restart_status != 0 )); then
      echo "ERROR: n8n restart failed; investigate immediately" >&2
      status=1
    fi
  fi
  if ! rm -rf -- "$WORK_DIR"; then
    echo "ERROR: temporary archive cleanup failed; inspect $WORK_DIR" >&2
    status=1
  fi
  if (( status != 0 )); then
    echo "ERROR: n8n backup failed; no new success marker was written" >&2
  fi
  exit "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

STAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)
VOLUME_ARCHIVE="$WORK_DIR/n8n-volume-$STAMP.tar.gz"
CONFIG_ARCHIVE="$WORK_DIR/n8n-config-$STAMP.tar.gz"
RECIPIENT=$(cat /etc/n8n-recovery/age-recipient.txt)

cd "$APP_DIR"
CONFIG_INPUTS=()
for path in .env compose.yml docker-compose.yml Caddyfile caddy_config local_files; do
  [[ -e "$path" ]] && CONFIG_INPUTS+=("$path")
done
if [[ ! -f compose.yml && ! -f docker-compose.yml ]]; then
  echo "No Compose file found in $APP_DIR" >&2
  exit 1
fi
[[ -f .env ]] || { echo "Missing required .env in $APP_DIR" >&2; exit 1; }

# Set first so cleanup also retries start if stop only partly succeeds.
RESTART_REQUIRED=1
sudo docker compose stop
sudo docker run --rm -v "$VOLUME_NAME":/data:ro -v "$WORK_DIR":/backup alpine \
  sh -c "tar -C /data -czf /backup/$(basename "$VOLUME_ARCHIVE") ."
sudo tar -C "$APP_DIR" -czf "$CONFIG_ARCHIVE" "${CONFIG_INPUTS[@]}"
sudo docker compose start
RESTART_REQUIRED=0

age -r "$RECIPIENT" -o "$VOLUME_ARCHIVE.age" "$VOLUME_ARCHIVE"
age -r "$RECIPIENT" -o "$CONFIG_ARCHIVE.age" "$CONFIG_ARCHIVE"
aws s3 cp "$VOLUME_ARCHIVE.age" "s3://$BUCKET/$PREFIX/" --endpoint-url "https://$REGION.digitaloceanspaces.com" --only-show-errors
aws s3 cp "$CONFIG_ARCHIVE.age" "s3://$BUCKET/$PREFIX/" --endpoint-url "https://$REGION.digitaloceanspaces.com" --only-show-errors
# Written only after both uploads finish. A successful upload is not a restore test.
date -u +%s > "$WORK_DIR/last-success"
mv "$WORK_DIR/last-success" "$BACKUP_DIR/last-success"
Shell command
sudo chown root:root /usr/local/sbin/backup-n8n-to-spaces
sudo chmod 700 /usr/local/sbin/backup-n8n-to-spaces
sudo /usr/local/sbin/backup-n8n-to-spaces

Schedule the job and expose failures

Save this block in /etc/cron.d/n8n-backup, not crontab -e. Set ownership to root:root and mode 644, and include the final newline. timedatectl shows the server timezone. The schedule uses that local time, which may not be UTC.

A backup that fails without an alert creates false confidence. Start with a daily schedule and a log. Send failures to the alerting system your team already watches. Set the frequency by the amount of automation state you can afford to lose. Daily backups are unlikely to be enough for a workflow that creates customer records every few minutes.

Every few weeks, list the bucket prefix and compare the newest archive pair against the schedule. The volume and configuration archives should share a timestamp. Investigate a missing pair before the next server change.

Configure a real alert route before enabling cron. MAILTO needs an installed, working mail transport and a monitored mailbox. Test delivery using one intentional backup failure and one stale marker. Do not rely on a log file alone. The 30-hour threshold below is an example for this daily schedule; set it from your recovery objective. Monitor total server loss from another system.

/usr/local/sbin/check-n8n-backup-age
#!/usr/bin/env bash
set -euo pipefail
marker=/var/backups/n8n/last-success
max_age_seconds=108000 # 30 hours for a daily job; set from your recovery objective.
if [[ ! -s "$marker" ]]; then
  echo 'ERROR: no successful n8n backup marker' >&2
  exit 1
fi
stamp=$(cat "$marker")
[[ "$stamp" =~ ^[0-9]+$ ]] || { echo 'ERROR: invalid backup marker' >&2; exit 1; }
now=$(date -u +%s)
if (( stamp > now || now - stamp > max_age_seconds )); then
  echo 'ERROR: n8n backup is stale or server clock needs investigation' >&2
  exit 1
fi
Shell command
sudo chown root:root /usr/local/sbin/check-n8n-backup-age
sudo chmod 700 /usr/local/sbin/check-n8n-backup-age
/etc/cron.d/n8n-backup
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=REPLACE_WITH_YOUR_APPROVED_BACKUP_ALERT_MAILBOX
23 3 * * * root /usr/local/sbin/backup-n8n-to-spaces >> /var/log/n8n-recovery.log 2>&1 || { echo 'n8n backup failed; inspect the restricted server log'; exit 1; }
17 * * * * root /usr/local/sbin/check-n8n-backup-age
Shell command
sudo bash -c 'set -a; . /etc/n8n-recovery/spaces.env; set +a; export AWS_DEFAULT_REGION=us-east-1; aws s3 ls s3://YOUR_PRIVATE_BUCKET/n8n/ --endpoint-url https://YOUR_SPACES_REGION.digitaloceanspaces.com'

Restore into an isolated test environment first

Use a fresh test Droplet. This restore builds a separate Compose configuration with port 5678 bound to localhost and an internal Docker network. It does not start the recovered production Compose stack or Caddy. Reach the dashboard through an SSH tunnel, without a public hostname or certificate.

This procedure requires the encryption key inside the .n8n volume and local files inside the recovery set. It does not cover an environment-injected key or external bind mounts. Pull the images before denying outbound traffic in the test firewall. Disable workflows before the first startup. Check a known record and a harmless manual workflow, then destroy the test Droplet.

Shell command
sudo -i
set -euo pipefail
apt-get update
apt-get install -y awscli age docker.io docker-compose-v2
docker compose version
install -d -m 700 /etc/n8n-recovery
nano /etc/n8n-recovery/restore-spaces.env
chmod 600 /etc/n8n-recovery/restore-spaces.env
set -a
. /etc/n8n-recovery/restore-spaces.env
set +a
export AWS_DEFAULT_REGION=us-east-1
STAMP=YYYY-MM-DDTHH-MM-SSZ
BUCKET=YOUR_PRIVATE_BUCKET
REGION=YOUR_SPACES_REGION
RESTORE_DIR=/srv/n8n-restore
[[ ! -e "$RESTORE_DIR" ]] || { echo 'Use a fresh restore directory'; exit 1; }
umask 077
install -d -m 700 "$RESTORE_DIR"
cd "$RESTORE_DIR"

aws s3 cp "s3://$BUCKET/n8n/n8n-volume-$STAMP.tar.gz.age" . --endpoint-url "https://$REGION.digitaloceanspaces.com"
aws s3 cp "s3://$BUCKET/n8n/n8n-config-$STAMP.tar.gz.age" . --endpoint-url "https://$REGION.digitaloceanspaces.com"
age -d -i /secure-path/n8n-recovery-key.txt -o n8n-volume.tar.gz "n8n-volume-$STAMP.tar.gz.age"
age -d -i /secure-path/n8n-recovery-key.txt -o n8n-config.tar.gz "n8n-config-$STAMP.tar.gz.age"
mkdir recovered-config
tar -xzf n8n-config.tar.gz -C recovered-config
tar -tzf n8n-volume.tar.gz | sed -n '1,40p'
# Keep recovered configuration for reference. Never source its .env or start its Compose stack.

# Copy the exact image digest recorded on production before the backup.
N8N_IMAGE=docker.n8n.io/n8nio/n8n@sha256:REPLACE_WITH_RECORDED_DIGEST
[[ "$N8N_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]] || { echo 'Set the recorded image digest'; exit 1; }
docker pull "$N8N_IMAGE"
docker pull alpine:3.22
RESTORE_VOLUME=n8n_restore_data
if docker volume inspect "$RESTORE_VOLUME" >/dev/null 2>&1; then echo 'Refusing an existing volume'; exit 1; fi
docker volume create "$RESTORE_VOLUME"
docker run --rm -v "$RESTORE_VOLUME":/data -v "$RESTORE_DIR":/backup:ro alpine:3.22 tar -C /data -xzf /backup/n8n-volume.tar.gz
mkdir -p recovered-config/local_files
cat > compose.recovery.yml <<EOF
services:
  n8n:
    image: $N8N_IMAGE
    restart: "no"
    ports:
      - "127.0.0.1:5678:5678"
    environment:
      N8N_HOST: localhost
      N8N_PORT: "5678"
      N8N_PROTOCOL: http
      WEBHOOK_URL: http://localhost:5678/
      N8N_SECURE_COOKIE: "false"
      N8N_DIAGNOSTICS_ENABLED: "false"
      N8N_VERSION_NOTIFICATIONS_ENABLED: "false"
    volumes:
      - n8n_data:/home/node/.n8n
      - ./recovered-config/local_files:/files:ro
    networks: [recovery]
volumes:
  n8n_data:
    external: true
    name: $RESTORE_VOLUME
networks:
  recovery:
    internal: true
EOF
docker compose -p n8n-recovery -f compose.recovery.yml config --quiet

# First deny outbound traffic in the separate test Cloud Firewall. Keep inbound SSH restricted to your IP.
# All required images were pulled in the previous step.
docker compose -p n8n-recovery -f compose.recovery.yml run --rm --no-deps n8n unpublish:workflow --all
docker compose -p n8n-recovery -f compose.recovery.yml up -d --pull never
curl --fail --connect-timeout 3 --max-time 5 --retry 30 --retry-delay 2 --retry-connrefused --retry-max-time 90 http://127.0.0.1:5678/healthz

On your administrator workstation:

Shell command
ssh -N -L 5678:127.0.0.1:5678 ADMIN_USER@TEST_DROPLET_IP
# Keep this terminal open and visit http://localhost:5678 in your browser.

Know when one Droplet is no longer enough

Move beyond one Droplet when you need a documented recovery-time objective, several people changing workflows, high execution volume, or an outage that can cause a material customer or financial problem. Use a managed database when it fits. Test database backups separately, keep secrets outside the Droplet, and name the people who can approve recovery.

Do not make an n8n workflow the only n8n backup. Keep the backup job outside the application it protects. Then it can still run when the workflow service breaks.

Frequently asked questions

Is a Droplet backup enough for n8n?

It is useful, but it is tied to one platform recovery mechanism. Keep a separate encrypted archive of the n8n data volume and configuration in private object storage, then test that you can restore it into a fresh environment.

Why must the n8n encryption key be in the recovery plan?

n8n uses that key to decrypt stored credentials. Without the matching key, a restored data store may contain credential records that cannot be used.

Can I back up a PostgreSQL-based n8n installation with the volume script?

Not by itself. Stop the application for the backup window and add a version-matched database dump and restore test. Treat the database dump and application configuration as one recovery set.