← All guidesGitHub
Workflow Automation

Run a Private n8n Server and Test the Restore

Deploy n8n on a DigitalOcean Droplet, limit access, back up encrypted data to private Spaces storage, and test a restore before a failure turns into an emergency.

Jump to articleGitHub: n8n-io/n8n-docker-caddy

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.

  1. Connect to the DropletLocal terminal
    Replace every highlighted value before running this command.
    ssh ADMIN_USER@YOUR_DROPLET_IP

    The remote shell prompt appears before you continue.

  2. Open the deployed directoryConnected Droplet
    cd /opt/n8n-docker-caddy
  3. Check the Compose services
    sudo docker compose ps
  4. List configured services
    sudo docker compose config --services
  5. Find the n8n container
    N8N_CONTAINER=$(sudo docker compose ps -q n8n)
  6. Read the running image ID
    N8N_IMAGE_ID=$(sudo docker inspect --format '{{.Image}}' "$N8N_CONTAINER")
  7. Save the exact image digest with the backup timestamp
    sudo docker image inspect --format '{{json .RepoDigests}}' "$N8N_IMAGE_ID"
  8. List Docker volumes
    sudo docker volume ls
  9. Inspect the deployed files
    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.

  1. Refresh package metadataConnected Droplet
    sudo apt-get update
  2. Install backup tools
    sudo apt-get install -y awscli age
  3. Create the protected directory
    sudo install -d -m 700 /etc/n8n-recovery
  4. Open the Spaces environment fileFile editor
    sudo nano /etc/n8n-recovery/spaces.env

    Save the access-key variables, exit the editor, then continue.

  5. Protect the Spaces environment fileConnected Droplet
    sudo chmod 600 /etc/n8n-recovery/spaces.env
  6. Open the age recipient fileFile editor
    sudo nano /etc/n8n-recovery/age-recipient.txt

    Save one public age1 recipient, exit the editor, then continue.

  7. Protect the age recipient fileConnected Droplet
    sudo chmod 600 /etc/n8n-recovery/age-recipient.txt

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-spacesFile contents
Replace every highlighted value before running this command.
#!/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"
STAMP=$(date -u +%Y-%m-%dT%H-%M-%SZ)
VOLUME_ARCHIVE="$BACKUP_DIR/n8n-volume-$STAMP.tar.gz"
CONFIG_ARCHIVE="$BACKUP_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
for required in .env; do
  [[ -f "$required" ]] || { echo "Missing required $required in $APP_DIR" >&2; exit 1; }
done

sudo docker compose stop
trap "sudo docker compose start" EXIT
sudo docker run --rm -v "$VOLUME_NAME":/data:ro -v "$BACKUP_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
trap - EXIT

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
rm -f "$VOLUME_ARCHIVE" "$CONFIG_ARCHIVE" "$VOLUME_ARCHIVE.age" "$CONFIG_ARCHIVE.age"
Make the script executable and run the first backupConnected Droplet
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.

/etc/cron.d/n8n-backup: 03:23 server local timeFile contents
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
23 3 * * * root /usr/local/sbin/backup-n8n-to-spaces >> /var/log/n8n-recovery.log 2>&1
  1. List archives as rootConnected Droplet
    Replace every highlighted value before running this 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.

Before continuing: Run this only on a fresh test machine. It installs packages, restores a new Docker volume and starts n8n with a localhost listener and an internal network.

  1. Open a root shell on the test DropletIsolated test machine
    sudo -i
  2. Install the restore tools
    set -euo pipefail
    apt-get update
    apt-get install -y awscli age docker.io docker-compose-v2
  3. Verify Docker Compose v2
    docker compose version
  4. Create the protected key directory
    install -d -m 700 /etc/n8n-recovery
  5. Save the separate Read key, then exit the editorFile editor
    nano /etc/n8n-recovery/restore-spaces.env
  6. Protect and load the read keyIsolated test machine
    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
  7. Set the selected timestamp
    Replace every highlighted value before running this command.
    STAMP=YYYY-MM-DDTHH-MM-SSZ
  8. Set the private bucket
    Replace every highlighted value before running this command.
    BUCKET=YOUR_PRIVATE_BUCKET
  9. Set the Spaces region
    Replace every highlighted value before running this command.
    REGION=YOUR_SPACES_REGION
  10. Create a fresh restore directory
    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"
  11. Download the volume archive
    aws s3 cp "s3://$BUCKET/n8n/n8n-volume-$STAMP.tar.gz.age" . --endpoint-url "https://$REGION.digitaloceanspaces.com"
  12. Download the configuration archive
    aws s3 cp "s3://$BUCKET/n8n/n8n-config-$STAMP.tar.gz.age" . --endpoint-url "https://$REGION.digitaloceanspaces.com"
  13. Decrypt the volume archive
    Replace every highlighted value before running this command.
    age -d -i /secure-path/n8n-recovery-key.txt -o n8n-volume.tar.gz "n8n-volume-$STAMP.tar.gz.age"
  14. Decrypt the configuration archive
    Replace every highlighted value before running this command.
    age -d -i /secure-path/n8n-recovery-key.txt -o n8n-config.tar.gz "n8n-config-$STAMP.tar.gz.age"
  15. Extract configuration for reference only
    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.
  16. Set the recorded image digest
    Replace every highlighted value before running this command.
    N8N_IMAGE=docker.n8n.io/n8nio/n8n@sha256:REPLACE_WITH_RECORDED_DIGEST
  17. Prepare volumes and an isolated Compose file
    [[ "$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
  18. Disable workflows and start after blocking outbound traffic
    # 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 update:workflow --all --active=false
    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
  19. Open the SSH tunnel from your workstationAdministrator workstation
    Replace every highlighted value before running this command.
    ssh -N -L 5678:127.0.0.1:5678 ADMIN_USER@TEST_DROPLET_IP

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.

Check your result

Expected result
Through the SSH tunnel at http://localhost:5678, an administrator can open a known workflow and run a harmless manual test while outbound traffic remains blocked.
Stop if
Stop if the archive pair has different timestamps, the encryption key is absent, the target is the production Droplet, the test firewall still allows outbound traffic before workflows are deactivated, or a recovered credential is exposed in a log or screenshot.
Next step
Record the restore date and result, rotate any credential exposed during testing, and destroy the temporary recovery environment.