← Back to Wiki
Proxmox / Backups / Cloud

Back Up DigitalOcean Droplets to Proxmox Backup Server

Run any public-facing service on a cloud droplet and it is easy to end up with zero backup coverage without noticing. A game server behind a tunnel, a small VPS-hosted app. Your hypervisor backs up the VMs and containers it manages. A droplet living on someone else's cloud is not one of those, so nothing backs it up automatically. Here is how to get real Proxmox Backup Server coverage on it, plus an obscure Docker bug that leaves your backups unrestorable while looking perfectly healthy.

Share on X

Why this gap is easy to miss

A Proxmox-hosted VM or container gets backed up because your hypervisor knows it exists and manages its disk directly. A DigitalOcean droplet is a different animal. It is a Linux box somewhere else, reachable over SSH, with no relationship to your home infrastructure unless you build one. It is completely normal to stand one up for a public-facing purpose, get it working, move on, and never circle back to backups. Nothing about the droplet running fine day to day tells you it is unprotected.

BE WARNED: check for this specifically. The failure mode is not an error message. It is an absence. Everything about the droplet looks healthy. Services up, uptime climbing. Meanwhile its data has never once been backed up anywhere. The only way to catch it is to check deliberately, host by host.

Two ways to install the client, and why it matters later

Proxmox Backup Server's client, proxmox-backup-client, only ships Debian packages. There is no native build for other distros. That leaves two real options on a droplet:

Getting the client connected to your home PBS

Your PBS instance lives on your home network. The droplet is out on the public internet. You need a path between them, and it pays to be deliberate about which one you pick.

BE WARNED: if you already have a WireGuard tunnel for something else, do not casually widen it for this. A tunnel client's AllowedIPs setting controls more than which packets go through it. A 0.0.0.0/0 full-tunnel config makes wg-quick build policy routing that redirects all outbound traffic from that host into the tunnel, not just traffic meant for the new peer. If that peer's handshake never completes, every other connection the host relied on vanishes into a tunnel going nowhere. Its own SSH session. Its public-facing service. Scope AllowedIPs to the single /32 address of the host you need to reach. Every time, no exceptions. This exact mistake has taken down a live production service before, and it is a one-line difference between "works" and "outage".

A simpler option avoids new tunnels entirely. Put a firewall rule on the droplet, scoped to your home's static public IP, allowing just the backup agent's port. For any one-time setup step needing the reverse direction, like registering a new backup target, open a temporary SSH tunnel and close it right after. This adds no always-on network path at all. Just a narrow inbound allowance and a connection you open and close on purpose.

Give the backup credential the least access it needs

Create a dedicated user and API token for this one droplet's backups, scoped to write new backups to its own namespace only. No read access to other hosts' backups. No delete. No admin rights. That token sits in a config file on a public-facing cloud host, so assume it leaks. The blast radius should be "someone can write junk backup data", not "someone can read or destroy every other backup on your PBS instance".

Back up a database as a real logical dump, not a raw file copy

If the droplet runs a database, resist the urge to point the backup client at its raw data directory. Copying a live database's files while it runs captures an inconsistent, half-written state. Fine for a static config file. Not fine for a database. Use the database's own dump tool to produce one transactionally consistent file, and back up that. pg_dumpall for Postgres, mysqldump for MySQL and MariaDB:

docker compose exec -T postgres pg_dumpall -U youruser > /path/to/dump/postgres.sql

It also makes backups smaller and restores dramatically simpler. You get a single SQL file to pipe straight into a fresh database, instead of reconstructing a running database engine's exact on-disk state.

Automate it with a systemd timer

A oneshot systemd service plus a timer unit is enough. Nothing fancier is needed on a single droplet:

[Unit]
Description=Backup my-droplet to Proxmox Backup Server

[Service]
Type=oneshot
ExecStart=/bin/bash /root/pbs-backup/run-backup.sh

Stagger the timer's start with a random delay if you do this across several droplets pointed at the same PBS instance. Otherwise they all hit it at the same second every night.

The gotcha: a Docker-based client can silently break restore only

BE WARNED: this one is genuinely obscure, and it never shows up as a backup failure. Go the Docker-image route above and your nightly backup job runs flawlessly, uploads real data, and reports success every night. Restoring from any of those backups fails outright. The difference between "looks completely fine" and "provably restorable" here is exactly one un-run command.

Here is the root cause. proxmox-backup-client uses O_TMPFILE, an anonymous unnamed temporary file, to stage downloaded chunk data. It needs that on every restore, and optionally during backup, to look up the previous backup's chunk index for incremental deduplication. Docker's default overlay2 storage driver does not support O_TMPFILE on a container's own writable layer, on at least some kernel versions still common in cloud droplet images. Two consequences:

The fix: bind-mount real host directories (on your droplet's actual filesystem, not the container's overlay layer) over /tmp and /root/.cache inside the container:

docker run --rm --network host \
  -v /root/pbs-backup/container-tmp:/tmp \
  -v /root/pbs-backup/container-cache:/root/.cache \
  --env-file /root/pbs-backup/pbs.env \
  -v /path/to/data:/backup-source/data:ro \
  your-pbs-client-image:latest backup \
  data.pxar:/backup-source/data \
  --backup-id my-droplet --ns my-droplet

Both paths now land on the droplet's real filesystem, where O_TMPFILE works normally. The same fix applies to a restore command run through the same image. On the native-package path this whole class of bug does not exist. One more reason to prefer it when your droplet's OS allows it.

How to confirm a bug like this, rather than guessing. Build a debug variant of the image with strace installed. Run the failing command under it. Grep the output for the failing syscall instead of trusting the client's much less specific error message. openat(..., O_TMPFILE, ...) = -1 EOPNOTSUPP is unambiguous in a way "Operation not supported" never is. Rule out the obvious suspects first and fast. Container networking mode, bind-mount type, seccomp, AppArmor, capabilities. All of those are one flag away to test, and none of them mattered here.

Test a real restore, not just that the job runs

That is the point of the section above. "The backup job reported success" and "this data is restorable" are two different claims. The only thing that makes them diverge silently is exactly the kind of bug that hides behind a green checkmark for months. Restore something and verify it, when you set this up and periodically after:

  1. Restore a known file from a recent snapshot into a scratch directory.
  2. Diff it against the live version. Confirm it is byte-for-byte identical, not just present.
  3. If you restore a database dump, load it into a throwaway database and query it. A file that copied successfully is no proof its contents are valid.

A backup you have never restored from is a hypothesis, not a safety net. A bug like the one above hides for months without a single symptom in your normal backup logs. So this is not a one-time setup step. Repeat it any time you change how the client runs.