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.
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.
- Replace every highlighted value before running this command.
ssh ADMIN_USER@YOUR_DROPLET_IPThe remote shell prompt appears before you continue.
cd /opt/n8n-docker-caddysudo docker compose pssudo docker compose config --servicesN8N_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 lssudo 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.
sudo apt-get updatesudo apt-get install -y awscli agesudo install -d -m 700 /etc/n8n-recoverysudo nano /etc/n8n-recovery/spaces.envSave the access-key variables, exit the editor, then continue.
sudo chmod 600 /etc/n8n-recovery/spaces.envsudo nano /etc/n8n-recovery/age-recipient.txtSave one public age1 recipient, exit the editor, then continue.
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/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"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-spacesSchedule 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.
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
- 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.
sudo -iset -euo pipefail apt-get update apt-get install -y awscli age docker.io docker-compose-v2docker compose versioninstall -d -m 700 /etc/n8n-recoverynano /etc/n8n-recovery/restore-spaces.envchmod 600 /etc/n8n-recovery/restore-spaces.env set -a . /etc/n8n-recovery/restore-spaces.env set +a export AWS_DEFAULT_REGION=us-east-1- Replace every highlighted value before running this command.
STAMP=YYYY-MM-DDTHH-MM-SSZ - Replace every highlighted value before running this command.
BUCKET=YOUR_PRIVATE_BUCKET - Replace every highlighted value before running this command.
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"- 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" - 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" 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.- Replace every highlighted value before running this command.
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 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- 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.