If your VPS disappeared tomorrow — hardware failure, provider outage, accidental rm -rf — what would you lose? Database content, nginx snippets you spent hours tuning, site uploads, configuration files you haven't touched in six months and forgot existed. Backups are the least glamorous part of running a server and the one you'll regret skipping the moment you need them.
I use two targets: Proton Drive for automated nightly backups (I pay for 1 TB, might as well use it), and a home server for manual or scripted pulls over Tailscale. These aren't my only backups — just the two that fit into the scope of this article. The cloud copy is convenient — it runs on cron, it's off-site, it's there when I forget to pull manually. The home server copy is local, fast to restore from, and not dependent on a third party keeping their service alive. If Proton Drive vanished tomorrow, I still have the basement. If the basement floods, I still have Proton. The principle isn't about these two specifically. It's about not having a single point of failure, and not confusing convenience with safety.
What you need
- rclone installed on the VPS — available in every major package manager
- A backup directory on the VPS containing whatever you want to back up — database dumps, nginx snippets, site files
- At least one remote storage target configured in rclone — Proton Drive, SFTP to a home server, rsync.net, Hetzner Storage Box, Backblaze B2, any S3-compatible service
- SSH key configured if your target is SFTP — the Hardening SSH guide covers key generation and authorized_keys setup
Step 1: Install rclone and configure remotes
# Install rclone
sudo apt install rclone
# Run the interactive config
rclone config
rclone's interactive config prompts you to add a remote, choose a backend type, and provide credentials. Here's how to set up the two remotes I use.
Remote 1: Home server over Tailscale (SFTP)
Select sftp as the backend type. rclone asks for the host, username, and authentication method. The critical bit: when asked for the SSH key, provide the path to your private key file — do not paste the key contents inline. rclone supports two mutually exclusive options: key_file (a path on disk) and key_pem (the key itself pasted as a single-line string with \n escapes). You pick one. The file path is simpler, easier to rotate, and avoids the formatting headache of escaping a multi-line key into one line:
name: homebackup
type: sftp
host: 100.100.100.100 # Tailscale IP of the home server
user: username
port: 22 # or your custom SSH port
key_file: /root/.ssh/id_ed25519
# Leave key_pem blank — key_file takes precedence
# Leave pass blank — key_file handles authentication
shell_type: unix
The host is the home server's Tailscale IP — a static address that works regardless of dynamic home IPs or ISP changes. If you haven't set up Tailscale between the VPS and home server, the reverse proxy guide covers the install and connection — the same network setup works for backups. If you prefer a dedicated backup provider instead of your own hardware, skip to the target comparison later in this article.
Remote 2: Proton Drive
Select protondrive as the backend type. rclone walks through authentication: your Proton email address, account password, and — if two-factor authentication is enabled — a current 2FA code. rclone stores encrypted credentials in its config file and uses OAuth-style token refresh on subsequent connections. The interactive prompts handle all of it:
name: proton
type: protondrive
username: your-proton-account@example.com
password: *** (encrypted at rest in rclone.conf)
2fa: ****** (one-time code during setup only)
After configuration, both remotes appear in rclone listremotes:
rclone listremotes
# homebackup:
# proton:
Test each remote before relying on it in cron:
# Test connectivity without listing your entire remote
rclone lsd homebackup: --max-depth=1
rclone lsd proton: --max-depth=1
rclone lsd lists only directories, and --max-depth=1 limits it to the top level — a quick connectivity check that doesn't recursively enumerate every file you've ever backed up. If the remote is reachable and authenticated, you'll see the top-level folders. If not, rclone returns an error. For a deeper spot-check, drop the depth flag or use rclone ls on a specific subdirectory.
A successful listing confirms the remote is reachable and authenticated. If the SFTP remote fails with a key-related error, double-check that the key file path is absolute (/root/.ssh/id_ed25519, not ~/.ssh/id_ed25519) — rclone resolves tildes in config, but cron environments don't always have the same $HOME context, and an absolute path avoids the ambiguity entirely.
Step 2: The backup scripts
My nightly backup runs in two stages. First, a script collects everything worth saving into a local /backups directory — database dumps, nginx configs, site files. Then a second script pushes that directory to the remote targets.
The push script — backup-offsite.sh:
#!/bin/bash
# backup-offsite.sh — push to cloud + home server, prune old copies
# Proton Drive (cloud — automated, off-site)
rclone copy /backups proton:linode/backups --transfers 1 --multi-thread-streams 1
rclone delete proton:linode/backups --min-age 30d
# Home server (local — fast restore, second copy)
rclone copy /backups homebackup:backups/linode/ --transfers 1 --multi-thread-streams 1
rclone delete homebackup:backups/linode/ --min-age 30d
The --transfers 1 --multi-thread-streams 1 flags are deliberate. On a small VPS — 1 vCPU, limited RAM — rclone's default parallel transfers can peg the CPU and spike memory usage while competing with nginx, PHP-FPM, and MariaDB for resources. A single transfer thread moves data more slowly but keeps the VPS responsive. Nightly backups are scheduled for 5:30 AM — nobody is browsing the site, and a few extra minutes don't matter.
The --min-age 30d flag on rclone delete keeps the last 30 days of backups on the remote. Older copies are pruned automatically. No manual cleanup, no filling up the remote with snapshots from six months ago.
To back up to the home server manually — after a major config change, before a dist-upgrade, whenever I want an extra copy right now — a separate script with no pruning:
#!/bin/bash
# backup-home.sh — manual snapshot to home server
rclone copy /backups homebackup:backups/linode/ --transfers 1 --multi-thread-streams 1
I run this manually as needed. It's fast over the LAN, pulls a fresh snapshot, and doesn't touch Proton Drive at all.
What your backup-daily.sh should do: The push scripts above assume something already collected database dumps and config files into /backups. How you organize that directory determines how easy a restore is. A few principles that hold regardless of what your setup looks like:
- One dump file per database. If you ever need to restore a single WordPress install, you don't want to grep through a monolithic
--all-databasesdump to find the right tables. Each site should already have its own database and dedicated MySQL user — the Securing Nginx and PHP guide covers why. If you inherited a shared-database setup, dump by table prefix instead of by database name. For WordPress specifically,wp db exportproduces a single-site dump without needing to know the database name or credentials — the WP-CLI guide covers the database commands that matter. - Use
--single-transactionon mysqldump. For InnoDB tables, this dumps a consistent snapshot without locking writes — your site keeps serving traffic during the backup window. If you're still on MyISAM, switch to InnoDB first, then back up. - Back up configs separately from content. Nginx snippets, crontabs, Let's Encrypt state, and database dumps go in one directory. Site uploads and themes go in another. When you need to restore a config change you made six months ago, you don't want to download your entire media library to find it.
- Skip what's transient. Cache directories, session files, wp-cron lock files, stale staging sites, abandoned test directories — if it can be regenerated or wasn't meant to be permanent, don't back it up.
The script itself can be as simple as a mysqldump per database, a cp -r for configs, and an rsync for content — all pointed at a dated directory under /backups. A symlink at /backups/latest pointing to the most recent run gives the push scripts a predictable path. The details of what to collect and exclude depend on what you're running. The principles don't.
Step 3: The cron schedule
Backups run as part of a layered nightly routine. The order matters — the abuse blacklist and blocklist update first (network and application security), then backups push to the remote, then certbot checks for expiring certificates:
# crontab — nightly security and maintenance
# 1. Network layer — crowd-sourced blacklist update (2 AM)
0 2 * * * /usr/local/bin/update-abuse-blacklist.sh > /dev/null 2>&1
# 2. Application layer — Squid blocklist update (3 AM)
0 3 * * * /etc/squid/update_blacklist.sh > /dev/null 2>&1
# 3. Daily backup — configs, databases, home (4 AM)
0 4 * * * /usr/local/bin/backup-daily.sh >> /var/log/backup.log 2>&1
# 4. Weekly www backup — code, themes, plugins (5 AM Sundays)
0 5 * * 0 /usr/local/bin/backup-www-weekly.sh >> /var/log/backup.log 2>&1
# 5. Push to off-site remotes (5:30 AM)
30 5 * * * /usr/local/bin/backup-offsite.sh >> /var/log/backup.log 2>&1
# 6. SSL certificate renewal check (6:45 AM)
45 6 * * * certbot renew -a webroot --webroot-path /usr/share/nginx/html \
--quiet --renew-hook "systemctl reload nginx"
# 7. Monthly Cloudflare IP update (3:30 AM, 1st of month)
30 3 1 * * /usr/local/bin/update-cloudflare-ips.sh
Backups run at 4 AM and 5 AM before the off-site push at 5:30 AM. The remote push finishes well before certbot runs at 6:45 AM. Spacing them out prevents resource contention — a database dump, an rclone transfer, and an SSL renewal all competing for the same single vCPU at the same time would slow everything down. This schedule keeps each job in its own lane.