← Back to Wiki
Proxmox / Backups / Cloud

Back Up DigitalOcean Droplets to Proxmox Backup Server

If you run any public-facing services on a cloud droplet — a game server behind a tunnel, a small VPS-hosted app — it's easy to end up with zero backup coverage on it without ever noticing. Your hypervisor backs up the VMs and containers it manages; a droplet living on someone else's cloud isn't one of those, so nothing does it automatically. Here's how to get real Proxmox Backup Server coverage on it, plus a genuinely obscure Docker bug that can leave your backups completely 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 entirely — it's just a Linux box somewhere else, reachable over SSH, with no relationship to your home infrastructure at all unless you deliberately build one. It's completely normal to stand one up for some 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's unprotected.

Check for this specifically. The failure mode isn't an error message; it's an absence. Everything about the droplet looks completely healthy — services up, uptime climbing — while its data has never once been backed up anywhere. The only way to catch it is to deliberately check, 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, with no native build for other distros. You've got 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's worth being deliberate about which one you pick.

If you already have a WireGuard tunnel for something else, don't 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 actually completes, every other connection the host was relying on — its own SSH session, its actual public-facing service — silently vanishes into a tunnel going nowhere. Scope AllowedIPs to the single /32 address of the host you actually need to reach, every time, no exceptions. This exact mistake has taken down a live production service before, and it's a one-line config difference between "works" and "outage."

A simpler option that avoids new tunnels entirely: a firewall rule on the droplet, scoped to your home's static public IP, allowing just the backup agent's port. Combine it with a temporary SSH tunnel for any one-time setup step that needs the reverse direction (registering a new backup target, for instance), closed again immediately after. Nothing about this approach adds a new always-on network path at all — just a narrowly-scoped 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 specifically 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. If this token ever leaks (it's sitting in a config file on a public-facing cloud host, after all), 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 just point the backup client at its raw data directory. Copying a live database's files while it's running risks capturing an inconsistent, half-written state — fine for a static config file, not fine for a database. Use the database's own dump tool instead (pg_dumpall for Postgres, mysqldump for MySQL/MariaDB) to produce one transactionally-consistent file, and back up that:

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

This also happens to make backups smaller and restores dramatically simpler — you get a single SQL file you can pipe straight back into a fresh database, rather than needing to reconstruct 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 — no need for anything fancier 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 time with a random delay if you're doing this across several droplets pointed at the same PBS instance, so they're not all hitting it at exactly the same second every night.

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

This one is genuinely obscure, and it will not show up as a backup failure. If you went the Docker-image route above, your nightly backup job can run flawlessly, upload real data, and report success every single night — while restoring from any of those backups fails outright. The difference between "looks completely fine" and "provably restorable" here is exactly one un-run command.

The root cause: proxmox-backup-client uses O_TMPFILE — an anonymous, unnamed temporary file — to stage downloaded chunk data. It needs this on every restore, and optionally during backup itself (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 on cloud droplet images. The result:

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, and the exact same fix applies to a restore command run through the same image. If you're on the native-package path instead of Docker, this whole class of bug doesn't exist — one more reason to prefer it when your droplet's OS allows it.

How to actually confirm a bug like this, rather than guessing: build a debug variant of the image with strace installed, run the failing command under it, and grep the output for the actual failing syscall rather than trusting the client's own (much less specific) error message. openat(..., O_TMPFILE, ...) = -1 EOPNOTSUPP is unambiguous in a way "Operation not supported" on its own never tells you. It's also worth ruling out the obvious suspects first and fast — container networking mode, bind-mount type, seccomp/AppArmor/capabilities — since all of those are one flag away to test, and none of them turned out to matter here.

Test a real restore — not just that the job runs

This is the actual point of the section above: a backup job reporting success is not the same claim as "this data is restorable," and the only way those two things can silently diverge is exactly the kind of bug that hides behind a green checkmark for months. After setting this up — and periodically afterward — actually restore something and verify it:

  1. Restore a known file from a recent snapshot into a scratch directory.
  2. Diff it against the live version to confirm it's byte-for-byte identical, not just present.
  3. If you're restoring a database dump, actually load it into a throwaway database and query it — a file that copied successfully isn't proof its contents are valid.

A backup you've never restored from is a hypothesis, not a safety net. Given how easily a bug like the one above can hide for months without a single symptom in your normal backup logs, this isn't a one-time setup step — it's worth repeating any time you change how the client runs.