Run a Private n8n Server Your Team Can Actually Restore
Deploy n8n on a DigitalOcean Droplet, limit access, back up its encrypted data to private Spaces storage, and prove a restore before an automation failure becomes an emergency.
Define recovery before you deploy the first workflow
n8n is not only a visual automation tool. It stores workflow definitions, credentials encrypted with its instance key, execution data, and configuration that tells it where callbacks should return. A screenshot of the canvas is not a backup. A successful recovery means a new server can start with the same encryption key, load a selected workflow, and run 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. This setup has no failover. Do not put customer records, production API keys, or financial actions into a workflow until you have tested both its normal failure handling and this recovery path.
- 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 test Droplet or a planned maintenance window for the restore test.
Deploy the n8n image, then finish the security boundary
The Marketplace app deploys a Droplet with the current stable n8n release and asks you to point a DNS A record at that server before completing setup. Use SSH-key login for the administrative account. Do not enable password SSH just to make the first login feel easier.
Create a cloud firewall before you invite anyone else. Allow TCP 80 and 443 for the web service. Allow TCP 22 only from your VPN exit or fixed administrative IP. Enable monitoring and backups on the Droplet, but treat those as a separate recovery layer. The backup in this guide protects the n8n data and configuration in portable form.
ssh ADMIN_USER@YOUR_DROPLET_IP
cd /opt/n8n-docker-caddy
sudo docker compose ps
sudo docker compose config --services
sudo docker volume ls
sudo ls -laKeep the callback address and encryption key stable
Complete the initial setup at your HTTPS domain, not by keeping the server IP in bookmarks. In the deployment directory, inspect the generated compose files and environment file before editing them. Set the public host, protocol, and webhook URL to the final HTTPS address using the variable names documented by the installed n8n version. Restart the compose stack only after you have saved a protected copy of the current configuration.
For the standard Docker and Caddy layout, the n8n data volume holds the SQLite database and its encryption material. That volume archive is the credential-recovery record. If you set N8N_ENCRYPTION_KEY outside that volume, keep the protected configuration archive too. Rotating a key is a deliberate maintenance task, not a troubleshooting step. A backup without the matching key may show credentials it 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 key used nowhere else
Create a private Spaces bucket such as n8n-recovery-your-team in a deliberate region. Do not enable public listing or a CDN. Enable versioning, then create a lifecycle policy that retains primary backup archives for a period your team has approved. Ninety days is a reasonable first policy when you have no stated recovery requirement. Then create a separate, bucket-limited Spaces key. DigitalOcean limited keys offer Read or Read/Write/Delete access. The upload job requires its own Read/Write/Delete key because there is no narrower write-only key. Store that key in a root-readable file on the Droplet with mode 600. Keep the age recipient public key there too. Keep the matching private identity key off the Droplet, such as in a team password manager with recovery access.
The backup remains unreadable in Spaces without the offline age identity. This protects the archive if a Spaces key leaks, but it also means you must test that the recovery owner can use the identity before treating the job as real.
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.txtVerify this workflow before scaling it
- Input
- Choose one recent encrypted volume and configuration archive pair, an empty test Droplet, and the recovery identity held outside the production server.
- Build
- Decrypt the matching pair on the test machine, restore it into a new n8n data volume, and start the same n8n version with the saved configuration.
- Expected result
- An administrator can sign in, open a selected workflow, and complete one harmless manual test without reconnecting its stored credential.
- 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.
Back up the actual persistent volume, not only the compose file
The 1-Click App can change its compose layout over time. Do not copy a volume name from this article. Use docker volume ls and the compose configuration you recorded earlier to identify the volume that holds n8n data. This guide covers the Marketplace Docker and Caddy layout with persistent n8n volume data. If you deliberately replace it with PostgreSQL, use the database vendor’s documented dump and restore procedure and test that as a separate recovery plan.
The script below stops the compose stack briefly, archives the named n8n volume and the deployment configuration, encrypts both with your public age recipient, then uploads the encrypted files. Set VOLUME_NAME only after you have inspected your server. It intentionally refuses to guess. It builds a configuration manifest from files that actually exist, includes the Caddy configuration when it is present, and fails if it cannot find a Compose file. Run it by hand once before scheduling it.
#!/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
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"Schedule the job and make a failed backup visible
A backup that fails quietly is worse than no backup because it encourages false confidence. Start with a daily schedule and a log, then connect failures to the alerting system your team already watches. Choose frequency from the amount of automation state you can afford to lose. For a workflow that creates customer records every few minutes, daily is probably not enough.
Every few weeks, list the bucket prefix and compare the newest pair of archives with the schedule. The volume and configuration archives should have the same timestamp. Investigate a missing pair before the next routine change to the server.
23 3 * * * root /usr/local/sbin/backup-n8n-to-spaces >> /var/log/n8n-recovery.log 2>&1set -a
. /etc/n8n-recovery/spaces.env
set +a
aws s3 ls s3://YOUR_PRIVATE_BUCKET/n8n/ --endpoint-url https://YOUR_SPACES_REGION.digitaloceanspaces.com | tail -4Restore into an isolated test environment before you need it
A restore test must not overwrite the production Droplet or contact production systems. Create a temporary Linux Droplet with a separate cloud firewall. Use a dedicated test subdomain, not the production hostname. During certificate issuance, allow TCP 80 and 443 plus the Caddy service’s required outbound traffic, but start only the reverse-proxy service. Then restrict HTTPS to the recovery administrator’s IP and deny outbound traffic before starting n8n. This prevents duplicate emails, payments, CRM writes, and webhook calls while the recovered instance starts.
Install awscli, age, Docker Engine, and the Docker Compose plugin on the test Droplet. Use a separate bucket-limited Read key there, not the production backup key. Install the same n8n image version, create an empty named data volume, then retrieve one matching volume and configuration archive. Use the private age identity from the recovery owner only on the test machine. Do not copy it to the production server. Extract the configuration archive into a fresh compose directory and restore the encrypted volume archive into the new empty volume. Check the restored image tag, public URL, and encryption-key handling before starting n8n. Sign in through the administrator-only firewall, deactivate all workflows, then run one manual Set-node test. Destroy the test Droplet or remove the recovered credentials from it when finished.
set -euo pipefail
TARGET_ENV=test
[[ "$TARGET_ENV" = test ]] || { echo "Refusing a production restore" >&2; exit 1; }
sudo apt-get update && sudo apt-get install -y awscli age docker.io docker-compose-plugin
sudo install -d -m 700 /etc/n8n-recovery
sudo nano /etc/n8n-recovery/restore-spaces.env
# Add a separate bucket-limited Read key, then chmod 600 that file.
set -a && . /etc/n8n-recovery/restore-spaces.env && set +a
STAMP=YYYY-MM-DDTHH-MM-SSZ
BUCKET=YOUR_PRIVATE_BUCKET
REGION=YOUR_SPACES_REGION
RESTORE_DIR=/srv/n8n-restore
RESTORE_VOLUME=n8n_restore_data
COMPOSE_FILE=docker-compose.yml
PRODUCTION_HOST=n8n.example.com
TEST_HOST=n8n-restore.example.com
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"
test -s "n8n-volume-$STAMP.tar.gz.age" && test -s "n8n-config-$STAMP.tar.gz.age"
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"
tar -xzf n8n-config.tar.gz -C "$RESTORE_DIR"
test -f "$COMPOSE_FILE"
tar -tzf n8n-volume.tar.gz | head -40
# Change both n8n and Caddy from the production hostname to the temporary test hostname.
grep -R "$PRODUCTION_HOST" .env Caddyfile
sed -i "s/$PRODUCTION_HOST/$TEST_HOST/g" .env Caddyfile
grep -E "N8N_HOST|WEBHOOK_URL|$TEST_HOST" .env Caddyfile
# With temporary 80/443 and outbound access, issue the certificate without starting n8n.
sudo docker compose -f "$COMPOSE_FILE" up -d caddy
# Now restrict HTTPS to the recovery IP and deny outbound traffic in the test firewall.
sudo docker volume create "$RESTORE_VOLUME"
sudo docker run --rm -v "$RESTORE_VOLUME":/data -v "$RESTORE_DIR":/backup:ro alpine \
sh -c "tar -C /data -xzf /backup/n8n-volume.tar.gz"
cat > compose.recovery.yml <<EOF
volumes:
n8n_data:
name: $RESTORE_VOLUME
EOF
# This override is for the standard n8n_data key. Verify it and the pinned image before startup:
sudo docker compose -f "$COMPOSE_FILE" -f compose.recovery.yml config | grep -E "image:|$RESTORE_VOLUME"
# Start only after the test firewall denies outbound traffic:
sudo docker compose -f "$COMPOSE_FILE" -f compose.recovery.yml up -dKnow when this small setup is no longer enough
Move beyond one Droplet when you need a documented recovery-time objective, multiple operators changing workflows, high execution volume, or a workflow whose outage can create a material customer or financial problem. At that point, use a managed database where appropriate, test database backups separately, manage secrets outside the Droplet, and write down who can approve recovery.
Do not use an n8n workflow as the only way to back up n8n. The backup job should stay outside the application it protects. That simple separation is what lets you recover when the workflow service is the thing that broke.